diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..95ee6e8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Always attempt union merge for project files +*.pbxproj merge=union diff --git a/.github/actions/set-xcode-version/action.yml b/.github/actions/set-xcode-version/action.yml new file mode 100644 index 0000000..1a6bb6c --- /dev/null +++ b/.github/actions/set-xcode-version/action.yml @@ -0,0 +1,31 @@ +name: 'Composite Xcode Path' +description: 'Get Xcode version to be used across all actions' +inputs: + xcode-version: + description: + 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' +outputs: + xcode-path: + description: "Path to current Xcode version" + value: ${{ steps.xcode-path.outputs.xcode-path }} +runs: + using: "composite" + steps: + - name: Set XCODE_PATH env var + env: + XCODE_PATH: "/Applications/Xcode_${{ inputs.xcode-version }}.app" + run: echo "XCODE_PATH=${{ env.XCODE_PATH }}" >> $GITHUB_ENV + shell: bash + - name: Set Xcode version + run: sudo xcode-select -s ${{ env.XCODE_PATH }} + shell: bash + - name: Enable new build system integration + run: defaults write com.apple.dt.XCBuild EnableSwiftBuildSystemIntegration 1 + shell: bash + - name: Output Xcode path + id: xcode-path + run: echo "xcode-path=$(echo ${{ env.XCODE_PATH }})" >> $GITHUB_OUTPUT + shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0f96f54 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1 @@ +At the moment we are not accepting contributions to the repository. \ No newline at end of file diff --git a/.github/workflows/auto-close-pr.yml b/.github/workflows/auto-close-pr.yml new file mode 100644 index 0000000..de2ca78 --- /dev/null +++ b/.github/workflows/auto-close-pr.yml @@ -0,0 +1,20 @@ +name: Auto-close PR +on: + pull_request_target: + types: [opened, reopened] + +jobs: + close: + name: Run + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - run: | + 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)." + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..78e3596 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,71 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '24 23 * * 1' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: python + build-mode: none + - language: swift + build-mode: manual + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - if: matrix.build-mode == 'manual' + uses: ./.github/actions/set-xcode-version + + - if: matrix.build-mode == 'manual' + shell: bash + run: | + xcodebuild \ + -scheme 'Copilot for Xcode' \ + -quiet \ + -archivePath build/Archives/CopilotForXcode.xcarchive \ + -configuration Release \ + -skipMacroValidation \ + -disableAutomaticPackageResolution \ + -workspace 'Copilot for Xcode.xcworkspace' \ + archive \ + CODE_SIGNING_ALLOWED="NO" + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..136e234 --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +# Created by https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager +# Edit at https://www.toptal.com/developers/gitignore?templates=xcode,macos,swift,swiftpackagemanager + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# End of https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager + +# Local build config +Config.local.xcconfig + +# Avoid checking in package resolved from swift packages +Tool/Package.resolved +Core/Package.resolved + +# Copilot language server +Server/node_modules/ + +# Releases +/releases/ +/release/ +/appcast.xml diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..a57b933 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,56 @@ +--allman false +--beforemarks +--binarygrouping 4,8 +--categorymark "MARK: %c" +--classthreshold 0 +--closingparen balanced +--commas always +--conflictmarkers reject +--decimalgrouping 3,6 +--elseposition same-line +--enumthreshold 0 +--exponentcase lowercase +--exponentgrouping disabled +--fractiongrouping disabled +--fragment false +--funcattributes preserve +--guardelse auto +--header ignore +--hexgrouping 4,8 +--hexliteralcase uppercase +--ifdef no-indent +--importgrouping testable-bottom +--indent 4 +--indentcase false +--lifecycle +--linebreaks lf +--maxwidth 100 +--modifierorder +--nospaceoperators ...,..< +--nowrapoperators +--octalgrouping 4,8 +--operatorfunc spaced +--patternlet hoist +--ranges spaced +--self remove +--selfrequired +--semicolons inline +--shortoptionals always +--smarttabs enabled +--stripunusedargs unnamed-only +--structthreshold 0 +--tabwidth unspecified +--trailingclosures +--trimwhitespace always +--typeattributes preserve +--varattributes preserve +--voidtype void +--wraparguments before-first +--wrapcollections disabled +--wrapparameters before-first +--xcodeindentation disabled +--yodaswap always + +--enable isEmpty + +--exclude Pods,**/Generated diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a1f82f0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at . All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift new file mode 100644 index 0000000..e34dee9 --- /dev/null +++ b/CommunicationBridge/ServiceDelegate.swift @@ -0,0 +1,165 @@ +import AppKit +import Foundation +import Logger +import XPCShared + +class ServiceDelegate: NSObject, NSXPCListenerDelegate { + func listener( + _: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + newConnection.exportedInterface = NSXPCInterface( + with: CommunicationBridgeXPCServiceProtocol.self + ) + + let exportedObject = XPCService() + newConnection.exportedObject = exportedObject + newConnection.resume() + + Logger.communicationBridge.info("Accepted new connection.") + + return true + } +} + +class XPCService: CommunicationBridgeXPCServiceProtocol { + static let eventHandler = EventHandler() + + func launchExtensionServiceIfNeeded( + withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void + ) { + Task { + await Self.eventHandler.launchExtensionServiceIfNeeded(withReply: reply) + } + } + + func quit(withReply reply: @escaping () -> Void) { + Task { + await Self.eventHandler.quit(withReply: reply) + } + } + + func updateServiceEndpoint( + endpoint: NSXPCListenerEndpoint, + withReply reply: @escaping () -> Void + ) { + Task { + await Self.eventHandler.updateServiceEndpoint(endpoint: endpoint, withReply: reply) + } + } +} + +actor EventHandler { + var endpoint: NSXPCListenerEndpoint? + let launcher = ExtensionServiceLauncher() + var exitTask: Task? + + init() { + Task { await rescheduleExitTask() } + } + + func launchExtensionServiceIfNeeded( + withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void + ) async { + rescheduleExitTask() + #if DEBUG + if let endpoint, !(await testXPCListenerEndpoint(endpoint)) { + self.endpoint = nil + } + reply(endpoint) + #else + if await launcher.isApplicationValid { + Logger.communicationBridge.info("Service app is still valid") + reply(endpoint) + } else { + endpoint = nil + await launcher.launch() + reply(nil) + } + #endif + } + + func quit(withReply reply: () -> Void) { + Logger.communicationBridge.info("Exiting service.") + listener.invalidate() + exit(0) + } + + func updateServiceEndpoint(endpoint: NSXPCListenerEndpoint, withReply reply: () -> Void) { + rescheduleExitTask() + self.endpoint = endpoint + reply() + } + + /// The bridge will kill itself when it's not used for a period. + /// It's fine that the bridge is killed because it will be launched again when needed. + private func rescheduleExitTask() { + exitTask?.cancel() + exitTask = Task { + #if DEBUG + try await Task.sleep(nanoseconds: 60_000_000_000) + Logger.communicationBridge.info("Exit will be called in release build.") + #else + try await Task.sleep(nanoseconds: 1_800_000_000_000) + Logger.communicationBridge.info("Exiting service.") + listener.invalidate() + exit(0) + #endif + } + } +} + +actor ExtensionServiceLauncher { + let appIdentifier = bundleIdentifierBase.appending(".ExtensionService") + let appURL = Bundle.main.bundleURL.appendingPathComponent( + "GitHub Copilot for Xcode Extension.app" + ) + var isLaunching: Bool = false + var application: NSRunningApplication? + var isApplicationValid: Bool { + guard let application else { return false } + if application.isTerminated { return false } + let identifier = application.processIdentifier + if let application = NSWorkspace.shared.runningApplications.first(where: { + $0.processIdentifier == identifier + }) { + Logger.communicationBridge.info( + "Service app found: \(application.processIdentifier) \(String(describing: application.bundleIdentifier))" + ) + return true + } + return false + } + + func launch() { + guard !isLaunching else { return } + 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." + ) + } + + self.application = app + self.isLaunching = false + } + } +} + diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift new file mode 100644 index 0000000..0cac3a3 --- /dev/null +++ b/CommunicationBridge/main.swift @@ -0,0 +1,19 @@ +import AppKit +import Foundation + +class AppDelegate: NSObject, NSApplicationDelegate {} + +let bundleIdentifierBase = Bundle(url: Bundle.main.bundleURL.appendingPathComponent( + "GitHub Copilot For Xcode Extension.app" +))?.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.github.CopilotForXcode" + +let serviceIdentifier = bundleIdentifierBase + ".CommunicationBridge" +let appDelegate = AppDelegate() +let delegate = ServiceDelegate() +let listener = NSXPCListener(machServiceName: serviceIdentifier) +listener.delegate = delegate +listener.resume() +let app = NSApplication.shared +app.delegate = appDelegate +app.run() + diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig new file mode 100644 index 0000000..63fae66 --- /dev/null +++ b/Config.debug.xcconfig @@ -0,0 +1,17 @@ +#include "Version.xcconfig" +SLASH = / // Otherwise a double slash is treated as a comment, even inside a quoted string + +HOST_APP_NAME = GitHub Copilot for Xcode Dev +BUNDLE_IDENTIFIER_BASE = dev.com.github.CopilotForXcode +SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)githubcopilotide.z13.web.core.windows.net/appcast.xml +SPARKLE_PUBLIC_KEY = EGlZbKpzATrZFfzr142PrZbmQr5opzdC8urMU8+dKL0= +APPLICATION_SUPPORT_FOLDER = dev.com.github.CopilotForXcode +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 + +// see also target Configs + +#include? "Config.local.xcconfig" diff --git a/Config.xcconfig b/Config.xcconfig new file mode 100644 index 0000000..5fba347 --- /dev/null +++ b/Config.xcconfig @@ -0,0 +1,15 @@ +#include "Version.xcconfig" +SLASH = / // Otherwise a double slash is treated as a comment, even inside a quoted string + +HOST_APP_NAME = GitHub Copilot for Xcode +BUNDLE_IDENTIFIER_BASE = com.github.CopilotForXcode +SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)githubcopilotide.z13.web.core.windows.net/appcast.xml +SPARKLE_PUBLIC_KEY = EGlZbKpzATrZFfzr142PrZbmQr5opzdC8urMU8+dKL0= +APPLICATION_SUPPORT_FOLDER = com.github.CopilotForXcode +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 + +// see also target Configs diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eb77086 --- /dev/null +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -0,0 +1,1358 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 3ABBEA292C8B9FE100C61D61 /* copilot-language-server in Resources */ = {isa = PBXBuildFile; fileRef = 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */; }; + 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 */; }; + 427C63282C6E868B000E557C /* OpenSettingsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */; }; + 42888D512C66B10100DEF835 /* AuthStatusChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42888D502C66B10100DEF835 /* AuthStatusChecker.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 */; }; + C80FFB962A95F58200704A25 /* AcceptPromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */; }; + C81291D72994FE6900196E12 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C81291D52994FE6900196E12 /* Main.storyboard */; }; + C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; }; + C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; }; + C81458962939EFDC00135263 /* GetSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */; }; + C814589B2939EFDC00135263 /* Copilot.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C814588C2939EFDC00135263 /* Copilot.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C8189B1A2938972F00C9DCDA /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8189B192938972F00C9DCDA /* App.swift */; }; + C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B1D2938973000C9DCDA /* Assets.xcassets */; }; + C8189B212938973000C9DCDA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8189B202938973000C9DCDA /* Preview Assets.xcassets */; }; + C8216B73298036EC00AD38C7 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8216B72298036EC00AD38C7 /* main.swift */; }; + C8216B782980370100AD38C7 /* ReloadLaunchAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */; }; + C8216B7D2980374300AD38C7 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C8216B7C2980374300AD38C7 /* ArgumentParser */; }; + C8216B802980378300AD38C7 /* Helper in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8216B70298036EC00AD38C7 /* Helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */ = {isa = PBXBuildFile; fileRef = C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */; }; + C8520301293C4D9000460097 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8520300293C4D9000460097 /* Helpers.swift */; }; + C861A6A329E5503F005C41A3 /* PromptToCodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */; }; + C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E6102994F6070056CB02 /* AppDelegate.swift */; }; + C861E6152994F6080056CB02 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C861E6142994F6080056CB02 /* Assets.xcassets */; }; + C861E61E2994F6150056CB02 /* Service in Frameworks */ = {isa = PBXBuildFile; productRef = C861E61D2994F6150056CB02 /* Service */; }; + C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C861E61F2994F6390056CB02 /* ServiceDelegate.swift */; }; + C86612F82A06AF74009197D9 /* HostApp in Frameworks */ = {isa = PBXBuildFile; productRef = C86612F72A06AF74009197D9 /* HostApp */; }; + C8738B662BE4D4B900609E7F /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B652BE4D4B900609E7F /* main.swift */; }; + C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */; }; + C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B6E2BE4F7A600609E7F /* XPCShared */; }; + C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B702BE4F8B700609E7F /* XPCController.swift */; }; + C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */; }; + C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7C2BE5363800609E7F /* ContentView.swift */; }; + C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B7E2BE5363900609E7F /* Assets.xcassets */; }; + C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B812BE5363900609E7F /* Preview Assets.xcassets */; }; + C8738B882BE5365000609E7F /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B872BE5365000609E7F /* Client */; }; + C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */ = {isa = PBXBuildFile; fileRef = C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */; }; + C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C8738B632BE4D4B900609E7F /* CommunicationBridge */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */; }; + C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */; }; + C87B03A5293B261200C77EAE /* AcceptSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */; }; + C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */; }; + C87B03A9293B262600C77EAE /* NextSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */; }; + C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */; }; + C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; }; + C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C81458902939EFDC00135263 /* XcodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; }; + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */; }; + C8C8B60929AFA35F00034BEE /* GitHub Copilot for Xcode Extension.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* GitHub Copilot for Xcode Extension.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; }; + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + C81291AF2994F92700196E12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C8189B0E2938972F00C9DCDA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C861E60D2994F6070056CB02; + remoteInfo = ExtensionService; + }; + C81458992939EFDC00135263 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C8189B0E2938972F00C9DCDA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C814588B2939EFDC00135263; + remoteInfo = EditorExtension; + }; + C8216B7E2980377E00AD38C7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C8189B0E2938972F00C9DCDA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C8216B6F298036EC00AD38C7; + remoteInfo = Helper; + }; + C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C8189B0E2938972F00C9DCDA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C8738B622BE4D4B900609E7F; + remoteInfo = CommunicationBridge; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + C814589F2939EFDC00135263 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C814589B2939EFDC00135263 /* Copilot.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + C8216B6E298036EC00AD38C7 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; + C828B27E2B1F7B3C00E7612A /* Copy Extension Point */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + C828B27F2B1F7B4F00E7612A /* ExtensionPoint.appextensionpoint in Copy Extension Point */, + ); + name = "Copy Extension Point"; + runOnlyForDeploymentPostprocessing = 0; + }; + C8520306293CF0EF00460097 /* Embed XPCService */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ../Applications; + dstSubfolderSpec = 6; + files = ( + C8738B8B2BE540DD00609E7F /* CommunicationBridge in Embed XPCService */, + C8216B802980378300AD38C7 /* Helper in Embed XPCService */, + C8C8B60929AFA35F00034BEE /* GitHub Copilot for Xcode Extension.app in Embed XPCService */, + ); + name = "Embed XPCService"; + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B612BE4D4B900609E7F /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = ""; + dstSubfolderSpec = 16; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C87B03AE293B2CF300C77EAE /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + C87B03AD293B2CF300C77EAE /* XcodeKit.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + C8C8B60829AFA32800034BEE /* Embed Service */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; + dstSubfolderSpec = 16; + files = ( + ); + name = "Embed Service"; + runOnlyForDeploymentPostprocessing = 0; + }; + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = Contents/Library/LaunchAgents; + dstSubfolderSpec = 1; + files = ( + C8738B8A2BE540D000609E7F /* bridgeLaunchAgent.plist in Copy Launch Agent */, + ); + name = "Copy Launch Agent"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* 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; }; + 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsCommand.swift; sourceTree = ""; }; + 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStatusChecker.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 = ""; }; + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptPromptToCodeCommand.swift; sourceTree = ""; }; + C81291D52994FE6900196E12 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; + C81291D92994FE7900196E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C814588E2939EFDC00135263 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; + C81458902939EFDC00135263 /* XcodeKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XcodeKit.framework; path = Library/Frameworks/XcodeKit.framework; sourceTree = DEVELOPER_DIR; }; + C81458932939EFDC00135263 /* SourceEditorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorExtension.swift; sourceTree = ""; }; + C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSuggestionsCommand.swift; sourceTree = ""; }; + C81458972939EFDC00135263 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C81458982939EFDC00135263 /* EditorExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EditorExtension.entitlements; sourceTree = ""; }; + C81458AD293A009600135263 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; + C81458AE293A009800135263 /* Config.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.debug.xcconfig; sourceTree = ""; }; + C8189B162938972F00C9DCDA /* GitHub Copilot for Xcode Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GitHub Copilot for Xcode Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + C8189B192938972F00C9DCDA /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + C8189B1D2938973000C9DCDA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C8189B202938973000C9DCDA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Copilot_for_Xcode.entitlements; sourceTree = ""; }; + C8189B282938979000C9DCDA /* Core */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Core; sourceTree = ""; }; + C81D181E2A1B509B006C1B70 /* Tool */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Tool; sourceTree = ""; }; + C81E867D296FE4420026E908 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; + C8216B70298036EC00AD38C7 /* Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = Helper; sourceTree = BUILT_PRODUCTS_DIR; }; + C8216B72298036EC00AD38C7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadLaunchAgent.swift; sourceTree = ""; }; + C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = ExtensionPoint.appextensionpoint; sourceTree = ""; }; + C8520300293C4D9000460097 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; + C8520308293D805800460097 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptToCodeCommand.swift; sourceTree = ""; }; + C861E60E2994F6070056CB02 /* GitHub Copilot for Xcode Extension.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GitHub Copilot for Xcode Extension.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + C861E6102994F6070056CB02 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + C861E6142994F6080056CB02 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C861E6192994F6080056CB02 /* ExtensionService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ExtensionService.entitlements; sourceTree = ""; }; + C861E61F2994F6390056CB02 /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; + C8738B632BE4D4B900609E7F /* CommunicationBridge */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = CommunicationBridge; sourceTree = BUILT_PRODUCTS_DIR; }; + C8738B652BE4D4B900609E7F /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; + C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = bridgeLaunchAgent.plist; sourceTree = ""; }; + C8738B702BE4F8B700609E7F /* XPCController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCController.swift; sourceTree = ""; }; + C8738B782BE5363800609E7F /* SandboxedClientTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SandboxedClientTester.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxedClientTesterApp.swift; sourceTree = ""; }; + C8738B7C2BE5363800609E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C8738B7E2BE5363900609E7F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C8738B812BE5363900609E7F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SandboxedClientTester.entitlements; sourceTree = ""; }; + C8738B892BE5379E00609E7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommand.swift; sourceTree = ""; }; + C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCommand.swift; sourceTree = ""; }; + C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Copilot-for-Xcode-Info.plist"; sourceTree = SOURCE_ROOT; }; + C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptSuggestionCommand.swift; sourceTree = ""; }; + C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectSuggestionCommand.swift; sourceTree = ""; }; + C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextSuggestionCommand.swift; sourceTree = ""; }; + C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousSuggestionCommand.swift; sourceTree = ""; }; + C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Menu.swift"; sourceTree = ""; }; + C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; }; + C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; }; + C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C81458892939EFDC00135263 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C87B03AC293B2CF300C77EAE /* XcodeKit.framework in Frameworks */, + C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */, + C882175C294187EF00A22FD3 /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8189B132938972F00C9DCDA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C86612F82A06AF74009197D9 /* HostApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8216B6D298036EC00AD38C7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C8216B7D2980374300AD38C7 /* ArgumentParser in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C861E60B2994F6070056CB02 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C861E61E2994F6150056CB02 /* Service in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B602BE4D4B900609E7F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B752BE5363800609E7F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B882BE5365000609E7F /* Client in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C814588D2939EFDC00135263 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C814588E2939EFDC00135263 /* Cocoa.framework */, + C81458902939EFDC00135263 /* XcodeKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + C81458922939EFDC00135263 /* EditorExtension */ = { + isa = PBXGroup; + children = ( + C81458932939EFDC00135263 /* SourceEditorExtension.swift */, + C8758E7129F04CF100D29C1C /* SeparatorCommand.swift */, + C8520300293C4D9000460097 /* Helpers.swift */, + C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */, + C87B03A4293B261200C77EAE /* AcceptSuggestionCommand.swift */, + C80FFB952A95F58200704A25 /* AcceptPromptToCodeCommand.swift */, + C87B03A6293B261900C77EAE /* RejectSuggestionCommand.swift */, + C87B03A8293B262600C77EAE /* NextSuggestionCommand.swift */, + C87B03AA293B262E00C77EAE /* PreviousSuggestionCommand.swift */, + C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */, + C8009C022941C576007AA7E8 /* SyncTextSettingsCommand.swift */, + C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, + C8758E6F29F04BFF00D29C1C /* CustomCommand.swift */, + C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */, + C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */, + C861A6A229E5503F005C41A3 /* PromptToCodeCommand.swift */, + C81458972939EFDC00135263 /* Info.plist */, + C81458982939EFDC00135263 /* EditorExtension.entitlements */, + 427C63272C6E868B000E557C /* OpenSettingsCommand.swift */, + ); + path = EditorExtension; + sourceTree = ""; + }; + C8189B0D2938972F00C9DCDA = { + isa = PBXGroup; + children = ( + C887BC832965D96000931567 /* DEVELOPMENT.md */, + C8520308293D805800460097 /* README.md */, + C8F103292A7A365000D28F4F /* launchAgent.plist */, + C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */, + C81E867D296FE4420026E908 /* Version.xcconfig */, + C81458AD293A009600135263 /* Config.xcconfig */, + C81458AE293A009800135263 /* Config.debug.xcconfig */, + C8CD828229B88006008D044D /* TestPlan.xctestplan */, + C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */, + C81D181E2A1B509B006C1B70 /* Tool */, + C8189B282938979000C9DCDA /* Core */, + C8189B182938972F00C9DCDA /* Copilot for Xcode */, + C81458922939EFDC00135263 /* EditorExtension */, + C8216B71298036EC00AD38C7 /* Helper */, + C861E60F2994F6070056CB02 /* ExtensionService */, + C8738B642BE4D4B900609E7F /* CommunicationBridge */, + C8738B792BE5363800609E7F /* SandboxedClientTester */, + C814588D2939EFDC00135263 /* Frameworks */, + C8189B172938972F00C9DCDA /* Products */, + ); + sourceTree = ""; + }; + C8189B172938972F00C9DCDA /* Products */ = { + isa = PBXGroup; + children = ( + C8189B162938972F00C9DCDA /* GitHub Copilot for Xcode Dev.app */, + C814588C2939EFDC00135263 /* Copilot.appex */, + C8216B70298036EC00AD38C7 /* Helper */, + C861E60E2994F6070056CB02 /* GitHub Copilot for Xcode Extension.app */, + C8738B632BE4D4B900609E7F /* CommunicationBridge */, + C8738B782BE5363800609E7F /* SandboxedClientTester.app */, + ); + name = Products; + sourceTree = ""; + }; + C8189B182938972F00C9DCDA /* Copilot for Xcode */ = { + isa = PBXGroup; + children = ( + 3ABBEA2A2C8BA00300C61D61 /* copilot-language-server-arm64 */, + 3ABBEA282C8B9FE100C61D61 /* copilot-language-server */, + C87B03A3293B24AB00C77EAE /* Copilot-for-Xcode-Info.plist */, + C8189B192938972F00C9DCDA /* App.swift */, + C8189B1D2938973000C9DCDA /* Assets.xcassets */, + C8189B222938973000C9DCDA /* Copilot_for_Xcode.entitlements */, + C8189B1F2938973000C9DCDA /* Preview Content */, + ); + path = "Copilot for Xcode"; + sourceTree = ""; + }; + C8189B1F2938973000C9DCDA /* Preview Content */ = { + isa = PBXGroup; + children = ( + C8189B202938973000C9DCDA /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + C8216B71298036EC00AD38C7 /* Helper */ = { + isa = PBXGroup; + children = ( + C8216B72298036EC00AD38C7 /* main.swift */, + C8216B772980370100AD38C7 /* ReloadLaunchAgent.swift */, + ); + path = Helper; + sourceTree = ""; + }; + C861E60F2994F6070056CB02 /* ExtensionService */ = { + isa = PBXGroup; + children = ( + C81291D92994FE7900196E12 /* Info.plist */, + C861E61F2994F6390056CB02 /* ServiceDelegate.swift */, + C861E6102994F6070056CB02 /* AppDelegate.swift */, + C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */, + C8738B702BE4F8B700609E7F /* XPCController.swift */, + C81291D52994FE6900196E12 /* Main.storyboard */, + C861E6142994F6080056CB02 /* Assets.xcassets */, + C861E6192994F6080056CB02 /* ExtensionService.entitlements */, + 42888D502C66B10100DEF835 /* AuthStatusChecker.swift */, + ); + path = ExtensionService; + sourceTree = ""; + }; + C8738B642BE4D4B900609E7F /* CommunicationBridge */ = { + isa = PBXGroup; + children = ( + C8738B652BE4D4B900609E7F /* main.swift */, + C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */, + ); + path = CommunicationBridge; + sourceTree = ""; + }; + C8738B792BE5363800609E7F /* SandboxedClientTester */ = { + isa = PBXGroup; + children = ( + C8738B892BE5379E00609E7F /* Info.plist */, + C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */, + C8738B7C2BE5363800609E7F /* ContentView.swift */, + C8738B7E2BE5363900609E7F /* Assets.xcassets */, + C8738B832BE5363900609E7F /* SandboxedClientTester.entitlements */, + C8738B802BE5363900609E7F /* Preview Content */, + ); + path = SandboxedClientTester; + sourceTree = ""; + }; + C8738B802BE5363900609E7F /* Preview Content */ = { + isa = PBXGroup; + children = ( + C8738B812BE5363900609E7F /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C814588B2939EFDC00135263 /* EditorExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = C814589C2939EFDC00135263 /* Build configuration list for PBXNativeTarget "EditorExtension" */; + buildPhases = ( + C81458882939EFDC00135263 /* Sources */, + C81458892939EFDC00135263 /* Frameworks */, + C814588A2939EFDC00135263 /* Resources */, + C87B03AE293B2CF300C77EAE /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = EditorExtension; + packageProductDependencies = ( + C882175B294187EF00A22FD3 /* Client */, + ); + productName = EditorExtension; + productReference = C814588C2939EFDC00135263 /* Copilot.appex */; + productType = "com.apple.product-type.xcode-extension"; + }; + C8189B152938972F00C9DCDA /* Copilot for Xcode */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8189B252938973000C9DCDA /* Build configuration list for PBXNativeTarget "Copilot for Xcode" */; + buildPhases = ( + C8189B122938972F00C9DCDA /* Sources */, + C8189B132938972F00C9DCDA /* Frameworks */, + C8189B142938972F00C9DCDA /* Resources */, + C814589F2939EFDC00135263 /* Embed Foundation Extensions */, + C8520306293CF0EF00460097 /* Embed XPCService */, + C8C8B60829AFA32800034BEE /* Embed Service */, + C8F1032A2A7A38D200D28F4F /* Copy Launch Agent */, + ); + buildRules = ( + ); + dependencies = ( + C8738B8D2BE540F900609E7F /* PBXTargetDependency */, + C81291B02994F92700196E12 /* PBXTargetDependency */, + C8216B7F2980377E00AD38C7 /* PBXTargetDependency */, + C814589A2939EFDC00135263 /* PBXTargetDependency */, + ); + name = "Copilot for Xcode"; + packageProductDependencies = ( + C86612F72A06AF74009197D9 /* HostApp */, + ); + productName = "Copilot for Xcode"; + productReference = C8189B162938972F00C9DCDA /* GitHub Copilot for Xcode Dev.app */; + productType = "com.apple.product-type.application"; + }; + C8216B6F298036EC00AD38C7 /* Helper */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8216B74298036EC00AD38C7 /* Build configuration list for PBXNativeTarget "Helper" */; + buildPhases = ( + C8216B6C298036EC00AD38C7 /* Sources */, + C8216B6D298036EC00AD38C7 /* Frameworks */, + C8216B6E298036EC00AD38C7 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Helper; + packageProductDependencies = ( + C8216B7C2980374300AD38C7 /* ArgumentParser */, + ); + productName = Helper; + productReference = C8216B70298036EC00AD38C7 /* Helper */; + productType = "com.apple.product-type.tool"; + }; + C861E60D2994F6070056CB02 /* ExtensionService */ = { + isa = PBXNativeTarget; + buildConfigurationList = C861E61A2994F6080056CB02 /* Build configuration list for PBXNativeTarget "ExtensionService" */; + buildPhases = ( + C861E60A2994F6070056CB02 /* Sources */, + C861E60B2994F6070056CB02 /* Frameworks */, + 3A60421A2C8955710006B34C /* ShellScript */, + C861E60C2994F6070056CB02 /* Resources */, + C828B27E2B1F7B3C00E7612A /* Copy Extension Point */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ExtensionService; + packageProductDependencies = ( + C861E61D2994F6150056CB02 /* Service */, + ); + productName = ExtensionService; + productReference = C861E60E2994F6070056CB02 /* GitHub Copilot for Xcode Extension.app */; + productType = "com.apple.product-type.application"; + }; + C8738B622BE4D4B900609E7F /* CommunicationBridge */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */; + buildPhases = ( + C8738B5F2BE4D4B900609E7F /* Sources */, + C8738B602BE4D4B900609E7F /* Frameworks */, + C8738B612BE4D4B900609E7F /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CommunicationBridge; + packageProductDependencies = ( + C8738B6E2BE4F7A600609E7F /* XPCShared */, + ); + productName = CommunicationBridge; + productReference = C8738B632BE4D4B900609E7F /* CommunicationBridge */; + productType = "com.apple.product-type.tool"; + }; + C8738B772BE5363800609E7F /* SandboxedClientTester */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */; + buildPhases = ( + C8738B742BE5363800609E7F /* Sources */, + C8738B752BE5363800609E7F /* Frameworks */, + C8738B762BE5363800609E7F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SandboxedClientTester; + packageProductDependencies = ( + C8738B872BE5365000609E7F /* Client */, + ); + productName = SandboxedClientTester; + productReference = C8738B782BE5363800609E7F /* SandboxedClientTester.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C8189B0E2938972F00C9DCDA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1520; + LastUpgradeCheck = 1410; + TargetAttributes = { + C814588B2939EFDC00135263 = { + CreatedOnToolsVersion = 14.1; + }; + C8189B152938972F00C9DCDA = { + CreatedOnToolsVersion = 14.1; + }; + C8216B6F298036EC00AD38C7 = { + CreatedOnToolsVersion = 14.1; + }; + C861E60D2994F6070056CB02 = { + CreatedOnToolsVersion = 14.2; + }; + C8738B622BE4D4B900609E7F = { + CreatedOnToolsVersion = 15.2; + }; + C8738B772BE5363800609E7F = { + CreatedOnToolsVersion = 15.2; + }; + }; + }; + buildConfigurationList = C8189B112938972F00C9DCDA /* Build configuration list for PBXProject "Copilot for Xcode" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C8189B0D2938972F00C9DCDA; + packageReferences = ( + C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, + ); + productRefGroup = C8189B172938972F00C9DCDA /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C8189B152938972F00C9DCDA /* Copilot for Xcode */, + C814588B2939EFDC00135263 /* EditorExtension */, + C8216B6F298036EC00AD38C7 /* Helper */, + C861E60D2994F6070056CB02 /* ExtensionService */, + C8738B622BE4D4B900609E7F /* CommunicationBridge */, + C8738B772BE5363800609E7F /* SandboxedClientTester */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C814588A2939EFDC00135263 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8189B142938972F00C9DCDA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8189B212938973000C9DCDA /* Preview Assets.xcassets in Resources */, + C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */, + 3ABBEA292C8B9FE100C61D61 /* copilot-language-server in Resources */, + 3ABBEA2B2C8BA00300C61D61 /* copilot-language-server-arm64 in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C861E60C2994F6070056CB02 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3ABBEA2C2C8BA00800C61D61 /* copilot-language-server-arm64 in Resources */, + C861E6152994F6080056CB02 /* Assets.xcassets in Resources */, + 3ABBEA2D2C8BA00B00C61D61 /* copilot-language-server in Resources */, + C81291D72994FE6900196E12 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B762BE5363800609E7F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B822BE5363900609E7F /* Preview Assets.xcassets in Resources */, + C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3A60421A2C8955710006B34C /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + 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"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C81458882939EFDC00135263 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */, + C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */, + C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */, + C8758E7029F04BFF00D29C1C /* CustomCommand.swift in Sources */, + C8758E7229F04CF100D29C1C /* SeparatorCommand.swift in Sources */, + C861A6A329E5503F005C41A3 /* PromptToCodeCommand.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 */, + C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, + C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */, + C8009C032941C576007AA7E8 /* SyncTextSettingsCommand.swift in Sources */, + C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */, + C81458962939EFDC00135263 /* GetSuggestionsCommand.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8189B122938972F00C9DCDA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8189B1A2938972F00C9DCDA /* App.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8216B6C298036EC00AD38C7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8216B73298036EC00AD38C7 /* main.swift in Sources */, + C8216B782980370100AD38C7 /* ReloadLaunchAgent.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C861E60A2994F6070056CB02 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */, + C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */, + C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */, + 42888D512C66B10100DEF835 /* AuthStatusChecker.swift in Sources */, + C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B5F2BE4D4B900609E7F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */, + C8738B662BE4D4B900609E7F /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C8738B742BE5363800609E7F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */, + C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + C81291B02994F92700196E12 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C861E60D2994F6070056CB02 /* ExtensionService */; + targetProxy = C81291AF2994F92700196E12 /* PBXContainerItemProxy */; + }; + C814589A2939EFDC00135263 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C814588B2939EFDC00135263 /* EditorExtension */; + targetProxy = C81458992939EFDC00135263 /* PBXContainerItemProxy */; + }; + C8216B7F2980377E00AD38C7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C8216B6F298036EC00AD38C7 /* Helper */; + targetProxy = C8216B7E2980377E00AD38C7 /* PBXContainerItemProxy */; + }; + C8738B8D2BE540F900609E7F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C8738B622BE4D4B900609E7F /* CommunicationBridge */; + targetProxy = C8738B8C2BE540F900609E7F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + C814589D2939EFDC00135263 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = EditorExtension/EditorExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = EditorExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; + PRODUCT_NAME = Copilot; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C814589E2939EFDC00135263 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = EditorExtension/EditorExtension.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = EditorExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(EXTESNION_BUNDLE_NAME)"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension"; + PRODUCT_NAME = Copilot; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C8189B232938973000C9DCDA /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C81458AE293A009800135263 /* Config.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C8189B242938973000C9DCDA /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C81458AD293A009600135263 /* Config.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + C8189B262938973000C9DCDA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Copilot for Xcode/Copilot_for_Xcode.entitlements"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; + PRODUCT_MODULE_NAME = Copilot_for_Xcode; + PRODUCT_NAME = "$(HOST_APP_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8189B272938973000C9DCDA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Copilot for Xcode/Copilot_for_Xcode.entitlements"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\""; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)"; + PRODUCT_NAME = "$(HOST_APP_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C8216B75298036EC00AD38C7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8216B76298036EC00AD38C7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C861E61B2994F6080056CB02 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ExtensionService/ExtensionService.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ExtensionService/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; + PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C861E61C2994F6080056CB02 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ExtensionService/ExtensionService.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = "$(APP_BUILD)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ExtensionService/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; + MARKETING_VERSION = "$(APP_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService"; + PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C8738B682BE4D4B900609E7F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8738B692BE4D4B900609E7F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C8738B852BE5363900609E7F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SandboxedClientTester/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.github.CopilotForXcode.SandboxedClientTester; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C8738B862BE5363900609E7F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = SandboxedClientTester/SandboxedClientTester.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\""; + DEVELOPMENT_TEAM = VEKTX9H2N7; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SandboxedClientTester/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.github.CopilotForXcode.SandboxedClientTester; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C814589C2939EFDC00135263 /* Build configuration list for PBXNativeTarget "EditorExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C814589D2939EFDC00135263 /* Debug */, + C814589E2939EFDC00135263 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8189B112938972F00C9DCDA /* Build configuration list for PBXProject "Copilot for Xcode" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8189B232938973000C9DCDA /* Debug */, + C8189B242938973000C9DCDA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8189B252938973000C9DCDA /* Build configuration list for PBXNativeTarget "Copilot for Xcode" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8189B262938973000C9DCDA /* Debug */, + C8189B272938973000C9DCDA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8216B74298036EC00AD38C7 /* Build configuration list for PBXNativeTarget "Helper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8216B75298036EC00AD38C7 /* Debug */, + C8216B76298036EC00AD38C7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C861E61A2994F6080056CB02 /* Build configuration list for PBXNativeTarget "ExtensionService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C861E61B2994F6080056CB02 /* Debug */, + C861E61C2994F6080056CB02 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8738B672BE4D4B900609E7F /* Build configuration list for PBXNativeTarget "CommunicationBridge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8738B682BE4D4B900609E7F /* Debug */, + C8738B692BE4D4B900609E7F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C8738B842BE5363900609E7F /* Build configuration list for PBXNativeTarget "SandboxedClientTester" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C8738B852BE5363900609E7F /* Debug */, + C8738B862BE5363900609E7F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C8216B7C2980374300AD38C7 /* ArgumentParser */ = { + isa = XCSwiftPackageProductDependency; + package = C8216B792980373800AD38C7 /* XCRemoteSwiftPackageReference "swift-argument-parser" */; + productName = ArgumentParser; + }; + C861E61D2994F6150056CB02 /* Service */ = { + isa = XCSwiftPackageProductDependency; + productName = Service; + }; + C86612F72A06AF74009197D9 /* HostApp */ = { + isa = XCSwiftPackageProductDependency; + productName = HostApp; + }; + C8738B6E2BE4F7A600609E7F /* XPCShared */ = { + isa = XCSwiftPackageProductDependency; + productName = XPCShared; + }; + C8738B872BE5365000609E7F /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; + C882175B294187EF00A22FD3 /* Client */ = { + isa = XCSwiftPackageProductDependency; + productName = Client; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = C8189B0E2938972F00C9DCDA /* Project object */; +} diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Copilot for Xcode.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme new file mode 100644 index 0000000..578b11e --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/CommunicationBridge.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme new file mode 100644 index 0000000..e26142f --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/EditorExtension.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/EditorExtension.xcscheme new file mode 100644 index 0000000..4844b10 --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/EditorExtension.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme new file mode 100644 index 0000000..c0e9b79 --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme new file mode 100644 index 0000000..41fadd0 --- /dev/null +++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/SandboxedClientTester.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Copilot for Xcode.xcworkspace/contents.xcworkspacedata b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..7fb7f27 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/IDETemplateMacros.plist b/Copilot for Xcode.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 0000000..ea58bd0 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,8 @@ + + + + + FILEHEADER + TODO: Remove this comment + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..9d2675d --- /dev/null +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,284 @@ +{ + "pins" : [ + { + "identity" : "cgeventoverride", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devm33/CGEventOverride", + "state" : { + "revision" : "571d36d63e68fac30e4a350600cd186697936f74", + "version" : "1.2.3" + } + }, + { + "identity" : "codablewrappers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GottaGetSwifty/CodableWrappers", + "state" : { + "revision" : "4eb46a4c656333e8514db8aad204445741de7d40", + "version" : "2.0.7" + } + }, + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, + { + "identity" : "copilotforxcodekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devm33/CopilotForXcodeKit", + "state" : { + "branch" : "main", + "revision" : "1f98fe9795766d3e37b5ae3d2e5f69f9b0af308b" + } + }, + { + "identity" : "fseventswrapper", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Frizlab/FSEventsWrapper", + "state" : { + "revision" : "e0c59a2ce2775e5f6642da6d19207445f10112d0", + "version" : "1.0.2" + } + }, + { + "identity" : "glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Bouke/Glob", + "state" : { + "revision" : "deda6e163d2ff2a8d7e138e2c3326dbd71157faf", + "version" : "1.0.5" + } + }, + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devm33/Highlightr", + "state" : { + "branch" : "master", + "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" + } + }, + { + "identity" : "jsonrpc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/JSONRPC", + "state" : { + "revision" : "5da978702aece6ba5c7879b0d253c180d61e4ef3", + "version" : "0.6.0" + } + }, + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devm33/KeyboardShortcuts", + "state" : { + "branch" : "main", + "revision" : "65fb410b0c6d3ed96623b460bab31ffce5f48b4d" + } + }, + { + "identity" : "languageclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageClient", + "state" : { + "revision" : "f0198ee0a102d266078f7d9c28f086f2989f988a", + "version" : "0.3.1" + } + }, + { + "identity" : "languageserverprotocol", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", + "state" : { + "revision" : "6e97f943dc024307c5524a80bd33cdbd1cc621de", + "version" : "0.8.0" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", + "version" : "6.0.0" + } + }, + { + "identity" : "operationplus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/OperationPlus", + "state" : { + "revision" : "1340f95dce3e93d742497d88db18f8676f4badf4", + "version" : "1.6.0" + } + }, + { + "identity" : "processenv", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChimeHQ/ProcessEnv", + "state" : { + "revision" : "29487b6581bb785c372c611c943541ef4309d051", + "version" : "0.3.1" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "87e4fcbac39912f9cdb9a9acf205cad60e1ca3bc", + "version" : "2.4.2" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "state" : { + "revision" : "433a23118f739078644ebeb4009e23d307af694a", + "version" : "1.10.4" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "350e1e119babe8525f9bd155b76640a5de270184", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing", + "state" : { + "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950", + "version" : "0.13.0" + } + }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "64f7f6c28c6a4d3c4b9da2ba02383e29ab48a8cf", + "version" : "1.2.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", + "version" : "509.0.2" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "7ab04c6e2e6a73d34d5a762970ef88bf0aedb084", + "version" : "1.4.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" + } + } + ], + "version" : 2 +} diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift new file mode 100644 index 0000000..094e32d --- /dev/null +++ b/Copilot for Xcode/App.swift @@ -0,0 +1,29 @@ +import Client +import HostApp +import LaunchAgentManager +import SwiftUI +import UpdateChecker +import XPCShared + +struct VisualEffect: NSViewRepresentable { + func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() } + func updateNSView(_ nsView: NSView, context: Context) { } +} + +@main +struct CopilotForXcodeApp: App { + var body: some Scene { + WindowGroup { + TabContainer() + .frame(minWidth: 800, minHeight: 600) + .background(VisualEffect().ignoresSafeArea()) + .onAppear { + UserDefaults.setupDefaultSettings() + } + .environment(\.updateChecker, UpdateChecker(hostBundle: Bundle.main)) + } + } +} + +var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } + diff --git a/Copilot for Xcode/Assets.xcassets/AccentColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0a30d46 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "CopilotforXcode-Icon@16w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "CopilotforXcode-Icon@16w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "CopilotforXcode-Icon@32w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "CopilotforXcode-Icon@32w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "CopilotforXcode-Icon@128w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "CopilotforXcode-Icon@128w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "CopilotforXcode-Icon@256w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "CopilotforXcode-Icon@256w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "CopilotforXcode-Icon@512w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "CopilotforXcode-Icon@512w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png new file mode 100644 index 0000000..3ee5242 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png new file mode 100644 index 0000000..88b20d1 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png new file mode 100644 index 0000000..2bb554d Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png new file mode 100644 index 0000000..ce02bac Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png new file mode 100644 index 0000000..7674f66 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png new file mode 100644 index 0000000..fc70596 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png new file mode 100644 index 0000000..ce02bac Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png new file mode 100644 index 0000000..4d52c81 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png new file mode 100644 index 0000000..fc70596 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png new file mode 100644 index 0000000..54da6e3 Binary files /dev/null and b/Copilot for Xcode/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png differ diff --git a/Copilot for Xcode/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..37eb3c3 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "40", + "green" : "23", + "red" : "25" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/BackgroundColorTop.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/BackgroundColorTop.colorset/Contents.json new file mode 100644 index 0000000..279761e --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/BackgroundColorTop.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "54", + "green" : "25", + "red" : "30" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorDefault.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorDefault.colorset/Contents.json new file mode 100644 index 0000000..3ece908 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorDefault.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x46", + "green" : "0x24", + "red" : "0x2C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorPressed.colorset/Contents.json b/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorPressed.colorset/Contents.json new file mode 100644 index 0000000..4754b65 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/ButtonBackgroundColorPressed.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.275", + "green" : "0.141", + "red" : "0.290" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Assets.xcassets/Contents.json b/Copilot for Xcode/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Copilot for Xcode/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot for Xcode/Copilot_for_Xcode.entitlements b/Copilot for Xcode/Copilot_for_Xcode.entitlements new file mode 100644 index 0000000..f557c73 --- /dev/null +++ b/Copilot for Xcode/Copilot_for_Xcode.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) + + com.apple.security.automation.apple-events + + com.apple.security.files.user-selected.read-only + + + diff --git a/Copilot for Xcode/Preview Content/Preview Assets.xcassets/Contents.json b/Copilot for Xcode/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Copilot for Xcode/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist new file mode 100644 index 0000000..a128dc2 --- /dev/null +++ b/Copilot-for-Xcode-Info.plist @@ -0,0 +1,30 @@ + + + + + + $(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 + SUEnableJavaScript + NO + SUFeedURL + $(SPARKLE_FEED_URL) + SUPublicEDKey + $(SPARKLE_PUBLIC_KEY) + TEAM_ID_PREFIX + $(TeamIdentifierPrefix) + + diff --git a/Core/Package.swift b/Core/Package.swift new file mode 100644 index 0000000..159138e --- /dev/null +++ b/Core/Package.swift @@ -0,0 +1,254 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import Foundation +import PackageDescription + +// MARK: - Package + +let package = Package( + name: "Core", + platforms: [.macOS(.v12)], + products: [ + .library( + name: "Service", + targets: [ + "Service", + "SuggestionInjector", + "FileChangeChecker", + "LaunchAgentManager", + "UpdateChecker", + ] + ), + .library( + name: "Client", + targets: [ + "Client", + ] + ), + .library( + name: "HostApp", + targets: [ + "HostApp", + "Client", + "LaunchAgentManager", + "UpdateChecker", + ] + ), + ], + dependencies: [ + .package(path: "../Tool"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"), + .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "1.10.4" + ), + // quick hack to support custom UserDefaults + // https://github.com/sindresorhus/KeyboardShortcuts + .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), + .package(url: "https://github.com/devm33/CGEventOverride", from: "1.2.1"), + .package(url: "https://github.com/devm33/Highlightr", branch: "master"), + ], + targets: [ + // MARK: - Main + + .target( + name: "Client", + dependencies: [ + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + .target( + name: "Service", + dependencies: [ + "SuggestionWidget", + "SuggestionService", + "ChatService", + "PromptToCodeService", + "ConversationTab", + "KeyBindingManager", + "XcodeThemeController", + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + .product(name: "Workspace", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + ]), + .testTarget( + name: "ServiceTests", + dependencies: [ + "Service", + "Client", + "SuggestionInjector", + .product(name: "XPCShared", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + ] + ), + + // MARK: - Host App + + .target( + name: "HostApp", + dependencies: [ + "Client", + "LaunchAgentManager", + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + + // MARK: - Suggestion Service + + .target( + name: "SuggestionService", + dependencies: [ + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "BuiltinExtension", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + .target( + name: "SuggestionInjector", + dependencies: [.product(name: "SuggestionBasic", package: "Tool")] + ), + .testTarget( + name: "SuggestionInjectorTests", + dependencies: ["SuggestionInjector"] + ), + + // MARK: - Prompt To Code + + .target( + name: "PromptToCodeService", + dependencies: [ + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ]), + + // MARK: - Chat + + .target( + name: "ChatService", + dependencies: [ + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + + .target( + name: "ConversationTab", + dependencies: [ + "ChatService", + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + + // MARK: - UI + + .target( + name: "SuggestionWidget", + dependencies: [ + "PromptToCodeService", + "ConversationTab", + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "SuggestionWidgetTests", dependencies: ["SuggestionWidget"]), + + // MARK: - Helpers + + .target(name: "FileChangeChecker"), + .target(name: "LaunchAgentManager"), + .target( + name: "UpdateChecker", + dependencies: [ + "Sparkle", + .product(name: "Preferences", package: "Tool"), + .product(name: "Logger", package: "Tool"), + ] + ), + + // MARK: Key Binding + + .target( + name: "KeyBindingManager", + dependencies: [ + .product(name: "Workspace", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "CGEventOverride", package: "CGEventOverride"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + ] + ), + .testTarget( + name: "KeyBindingManagerTests", + dependencies: ["KeyBindingManager"] + ), + + // MARK: Theming + + .target( + name: "XcodeThemeController", + dependencies: [ + .product(name: "Preferences", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Highlightr", package: "Highlightr"), + ] + ), + + ] +) + diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift new file mode 100644 index 0000000..06f8c4d --- /dev/null +++ b/Core/Sources/ChatService/ChatService.swift @@ -0,0 +1,259 @@ +import ChatAPIService +import Combine +import Foundation +import GitHubCopilotService +import Preferences +import ConversationServiceProvider +import BuiltinExtension + +public protocol ChatServiceType { + var memory: ContextAwareAutoManagedChatMemory { get set } + func send(_ id: String, content: 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 +} + +public final class ChatService: ChatServiceType, ObservableObject { + + public var memory: ContextAwareAutoManagedChatMemory + @Published public internal(set) var chatHistory: [ChatMessage] = [] + @Published public internal(set) var isReceivingMessage = false + + private let conversationProvider: ConversationServiceProvider? + private let conversationProgressHandler: ConversationProgressHandler + private var cancellables = Set() + private var activeRequestId: String? + private var conversationId: String? + + init(provider: any ConversationServiceProvider, + memory: ContextAwareAutoManagedChatMemory = ContextAwareAutoManagedChatMemory(), + conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared) { + self.memory = memory + self.conversationProvider = provider + self.conversationProgressHandler = conversationProgressHandler + memory.chatService = self + + subscribeToNotifications() + } + + private func subscribeToNotifications() { + memory.observeHistoryChange { [weak self] in + Task { [weak self] in + guard let memory = self?.memory else { return } + self?.chatHistory = await memory.history + } + } + + conversationProgressHandler.onBegin.sink { [weak self] (token, progress) in + self?.handleProgressBegin(token: token, progress: progress) + }.store(in: &cancellables) + + conversationProgressHandler.onProgress.sink { [weak self] (token, progress) in + self?.handleProgressReport(token: token, progress: progress) + }.store(in: &cancellables) + + conversationProgressHandler.onEnd.sink { [weak self] (token, progress) in + self?.handleProgressEnd(token: token, progress: progress) + }.store(in: &cancellables) + } + + public static func service() -> ChatService { + let provider = BuiltinExtensionConversationServiceProvider( + extension: GitHubCopilotExtension.self + ) + return ChatService(provider: provider) + } + + public func send(_ id: String, content: String) 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 request = ConversationRequest(workDoneToken: workDoneToken, + content: content, workspaceFolder: "", skills: []) + try await send(request) + } + + public func sendAndWait(_ id: String, content: String) async throws -> String { + try await send(id, content: content) + if let reply = await memory.history.last(where: { $0.role == .assistant })?.content { + return reply + } + return "" + } + + public func stopReceivingMessage() async { + if let activeRequestId = activeRequestId { + do { + try await conversationProvider?.stopReceivingMessage(activeRequestId) + } catch { + print("Failed to cancel ongoing request with WDT: \(activeRequestId)") + } + } + resetOngoingRequest() + } + + public func clearHistory() async { + await memory.clearHistory() + if let activeRequestId = activeRequestId { + do { + try await conversationProvider?.stopReceivingMessage(activeRequestId) + } catch { + print("Failed to cancel ongoing request with WDT: \(activeRequestId)") + } + } + resetOngoingRequest() + } + + public func deleteMessage(id: String) async { + await memory.removeMessage(id) + } + + public func resendMessage(id: String) async throws { + if let message = (await memory.history).first(where: { $0.id == id }) + { + do { + try await send(id, content: message.content) + } catch { + print("Failed to resend message") + } + } + } + + public func setMessageAsExtraPrompt(id: String) async { + if let message = (await memory.history).first(where: { $0.id == id }) + { + await mutateHistory { history in + history.append(.init( + role: .assistant, + content: message.content + )) + } + } + } + + public func mutateHistory(_ mutator: @escaping (inout [ChatMessage]) -> Void) async { + await memory.mutateHistory(mutator) + } + + public func handleCustomCommand(_ command: CustomCommand) async throws { + struct CustomCommandInfo { + var specifiedSystemPrompt: String? + var extraSystemPrompt: String? + var sendingMessageImmediately: String? + var name: String? + } + + let info: CustomCommandInfo? = { + switch command.feature { + case let .chatWithSelection(extraSystemPrompt, prompt, useExtraSystemPrompt): + let updatePrompt = useExtraSystemPrompt ?? true + return .init( + extraSystemPrompt: updatePrompt ? extraSystemPrompt : nil, + sendingMessageImmediately: prompt, + name: command.name + ) + case let .customChat(systemPrompt, prompt): + return .init( + specifiedSystemPrompt: systemPrompt, + extraSystemPrompt: "", + sendingMessageImmediately: prompt, + name: command.name + ) + case .promptToCode: return nil + case .singleRoundDialog: return nil + } + }() + + guard let info else { return } + + let templateProcessor = CustomCommandTemplateProcessor() + + if info.specifiedSystemPrompt != nil || info.extraSystemPrompt != nil { + await mutateHistory { history in + history.append(.init( + role: .assistant, + content: "" + )) + } + } + + if let sendingMessageImmediately = info.sendingMessageImmediately, + !sendingMessageImmediately.isEmpty + { + try await send(UUID().uuidString, content: templateProcessor.process(sendingMessageImmediately)) + } + } + + public func upvote(_ id: String, _ rating: ConversationRating) async { + try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + } + + public func downvote(_ id: String, _ rating: ConversationRating) async { + try? await conversationProvider?.rateConversation(turnId: id, rating: rating) + } + + public func copyCode(_ id: String) async { + // TODO: pass copy code info to Copilot server + } + + public func handleSingleRoundDialogCommand( + systemPrompt: String?, + overwriteSystemPrompt: Bool, + prompt: String + ) async throws -> String { + let templateProcessor = CustomCommandTemplateProcessor() + return try await sendAndWait(UUID().uuidString, content: templateProcessor.process(prompt)) + } + + private func handleProgressBegin(token: String, progress: ConversationProgress) { + guard let workDoneToken = activeRequestId, workDoneToken == token else { return } + conversationId = progress.conversationId + + Task { + if var lastUserMessage = await memory.history.last(where: { $0.role == .user }) { + lastUserMessage.turnId = progress.turnId + } + } + } + + private func handleProgressReport(token: String, progress: ConversationProgress) { + guard let workDoneToken = activeRequestId, workDoneToken == token, let reply = progress.reply else { return } + + Task { + let message = ChatMessage(id: progress.turnId, role: .assistant, content: reply) + await memory.appendMessage(message) + } + } + + private func handleProgressEnd(token: String, progress: ConversationProgress) { + guard let workDoneToken = activeRequestId, workDoneToken == token else { return } + resetOngoingRequest() + } + + private func resetOngoingRequest() { + activeRequestId = nil + isReceivingMessage = false + } + + private func send(_ request: ConversationRequest) async throws { + guard !isReceivingMessage else { throw CancellationError() } + isReceivingMessage = true + + do { + if let conversationId = conversationId { + try await conversationProvider?.createTurn(with: conversationId, request: request) + } else { + try await conversationProvider?.createConversation(request) + } + } catch { + resetOngoingRequest() + throw error + } + } +} + diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift new file mode 100644 index 0000000..e86ede8 --- /dev/null +++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatMemory.swift @@ -0,0 +1,26 @@ +import Foundation +import ChatAPIService + +public final class ContextAwareAutoManagedChatMemory: ChatMemory { + private let memory: AutoManagedChatMemory + weak var chatService: ChatService? + + public var history: [ChatMessage] { + get async { await memory.history } + } + + func observeHistoryChange(_ observer: @escaping () -> Void) { + memory.observeHistoryChange(observer) + } + + init() { + memory = AutoManagedChatMemory( + systemPrompt: "" + ) + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async { + await memory.mutateHistory(update) + } +} + diff --git a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift new file mode 100644 index 0000000..2a54d32 --- /dev/null +++ b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift @@ -0,0 +1,53 @@ +import AppKit +import Foundation +import SuggestionBasic +import XcodeInspector + +public struct CustomCommandTemplateProcessor { + public init() {} + + public func process(_ text: String) async -> String { + let info = await getEditorInformation() + let editorContent = info.editorContent + let updatedText = text + .replacingOccurrences(of: "{{selected_code}}", with: """ + \(editorContent?.selectedContent.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") + """) + .replacingOccurrences( + of: "{{active_editor_language}}", + with: info.language.rawValue + ) + .replacingOccurrences( + of: "{{active_editor_file_url}}", + with: info.documentURL?.path ?? "" + ) + .replacingOccurrences( + of: "{{active_editor_file_name}}", + with: info.documentURL?.lastPathComponent ?? "" + ) + .replacingOccurrences( + of: "{{clipboard}}", + with: NSPasteboard.general.string(forType: .string) ?? "" + ) + return updatedText + } + + struct EditorInformation { + let editorContent: SourceEditor.Content? + let language: CodeLanguage + let documentURL: URL? + } + + func getEditorInformation() async -> EditorInformation { + let editorContent = await XcodeInspector.shared.safe.focusedEditor?.getContent() + let documentURL = await XcodeInspector.shared.safe.activeDocumentURL + let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext + + return .init( + editorContent: editorContent, + language: language, + documentURL: documentURL + ) + } +} + diff --git a/Core/Sources/Client/XPCService.swift b/Core/Sources/Client/XPCService.swift new file mode 100644 index 0000000..24a50ba --- /dev/null +++ b/Core/Sources/Client/XPCService.swift @@ -0,0 +1,14 @@ +import Foundation +import Logger +import os.log +import XPCShared + +let shared = XPCExtensionService(logger: .client) + +public func getService() throws -> XPCExtensionService { + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + struct RunningInPreview: Error {} + throw RunningInPreview() + } + return shared +} diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift new file mode 100644 index 0000000..c7f2649 --- /dev/null +++ b/Core/Sources/ConversationTab/Chat.swift @@ -0,0 +1,387 @@ +import ChatService +import ComposableArchitecture +import Foundation +import ChatAPIService +import Preferences +import Terminal +import ConversationServiceProvider + +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 init(id: String, role: Role, text: String, references: [Reference]) { + self.id = id + self.role = role + self.text = text + self.references = references + } +} + +private var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" +} + +@Reducer +struct Chat { + public typealias MessageID = String + + @ObservableState + struct State: Equatable { + var title: String = "Chat" + var typedMessage = "" + var history: [DisplayedChatMessage] = [] + var isReceivingMessage = false + var chatMenu = ChatMenu.State() + var focusedField: Field? + + enum Field: String, Hashable { + case textField + } + } + + enum Action: Equatable, BindableAction { + case binding(BindingAction) + + case appear + case refresh + case sendButtonTapped(String) + case returnButtonTapped + case stopRespondingButtonTapped + case clearButtonTap + case deleteMessageButtonTapped(MessageID) + case resendMessageButtonTapped(MessageID) + case setAsExtraPromptButtonTapped(MessageID) + case focusOnTextField + case referenceClicked(DisplayedChatMessage.Reference) + case upvote(MessageID, ConversationRating) + case downvote(MessageID, ConversationRating) + case copyCode(MessageID) + + case observeChatService + case observeHistoryChange + case observeIsReceivingMessageChange + + case historyChanged + case isReceivingMessageChanged + + case chatMenu(ChatMenu.Action) + } + + let service: ChatService + let id = UUID() + + enum CancelID: Hashable { + case observeHistoryChange(UUID) + case observeIsReceivingMessageChange(UUID) + case sendMessage(UUID) + } + + @Dependency(\.openURL) var openURL + + var body: some ReducerOf { + BindingReducer() + + Scope(state: \.chatMenu, action: /Action.chatMenu) { + ChatMenu(service: service) + } + + Reduce { state, action in + switch action { + case .appear: + return .run { send in + if isPreview { return } + await send(.observeChatService) + await send(.historyChanged) + await send(.isReceivingMessageChanged) + await send(.focusOnTextField) + await send(.refresh) + } + + case .refresh: + return .run { send in + await send(.chatMenu(.refresh)) + } + + case let .sendButtonTapped(id): + guard !state.typedMessage.isEmpty else { return .none } + let message = state.typedMessage + state.typedMessage = "" + return .run { _ in + try await service.send(id, content: message) + }.cancellable(id: CancelID.sendMessage(self.id)) + + case .returnButtonTapped: + state.typedMessage += "\n" + return .none + + case .stopRespondingButtonTapped: + return .merge( + .run { _ in + await service.stopReceivingMessage() + }, + .cancel(id: CancelID.sendMessage(id)) + ) + + case .clearButtonTap: + return .run { _ in + await service.clearHistory() + } + + case let .deleteMessageButtonTapped(id): + return .run { _ in + await service.deleteMessage(id: id) + } + + case let .resendMessageButtonTapped(id): + return .run { _ in + try await service.resendMessage(id: id) + } + + case let .setAsExtraPromptButtonTapped(id): + return .run { _ in + await service.setMessageAsExtraPrompt(id: id) + } + + case let .referenceClicked(reference): + let fileURL = URL(fileURLWithPath: reference.uri) + return .run { _ in + if FileManager.default.fileExists(atPath: fileURL.path) { + let terminal = Terminal() + do { + _ = try await terminal.runCommand( + "/bin/bash", + arguments: [ + "-c", + "xed -l \(reference.startLine ?? 0) \"\(reference.uri)\"", + ], + environment: [:] + ) + } catch { + print(error) + } + } else if let url = URL(string: reference.uri), url.scheme != nil { + await openURL(url) + } + } + + case .focusOnTextField: + state.focusedField = .textField + return .none + + case .observeChatService: + return .run { send in + await send(.observeHistoryChange) + await send(.observeIsReceivingMessageChange) + } + + case .observeHistoryChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$chatHistory.sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + let debouncedHistoryChange = TimedDebounceFunction(duration: 0.2) { + await send(.historyChanged) + } + + for await _ in stream { + await debouncedHistoryChange() + } + }.cancellable(id: CancelID.observeHistoryChange(id), cancelInFlight: true) + + case .observeIsReceivingMessageChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = service.$isReceivingMessage + .sink { _ in + continuation.yield() + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await _ in stream { + await send(.isReceivingMessageChanged) + } + }.cancellable( + id: CancelID.observeIsReceivingMessageChange(id), + cancelInFlight: true + ) + + case .historyChanged: + state.history = service.chatHistory.flatMap { message in + var all = [DisplayedChatMessage]() + all.append(.init( + id: message.id, + role: { + switch message.role { + case .system: return .ignored + case .user: return .user + case .assistant: return .assistant + } + }(), + text: message.summary ?? message.content, + references: message.references.map { + .init( + title: $0.title, + subtitle: $0.subTitle, + uri: $0.uri, + startLine: $0.startLine, + kind: $0.kind + ) + } + )) + + 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 + return .none + + case .binding: + return .none + + case .chatMenu: + return .none + case let .upvote(id, rating): + return .run { _ in + await service.upvote(id, rating) + } + case let .downvote(id, rating): + return .run { _ in + await service.downvote(id, rating) + } + case let .copyCode(id): + return .run { _ in + await service.copyCode(id) + } + } + } + } +} + +@Reducer +struct ChatMenu { + @ObservableState + struct State: Equatable { + var systemPrompt: String = "" + var extraSystemPrompt: String = "" + var temperatureOverride: Double? = nil + var chatModelIdOverride: String? = nil + } + + enum Action: Equatable { + case appear + case refresh + case customCommandButtonTapped(CustomCommand) + } + + let service: ChatService + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + return .run { + await $0(.refresh) + } + + case .refresh: + return .none + + case let .customCommandButtonTapped(command): + return .run { _ in + try await service.handleCustomCommand(command) + } + } + } + } +} + +private actor TimedDebounceFunction { + let duration: TimeInterval + let block: () async -> Void + + var task: Task? + var lastFireTime: Date = .init(timeIntervalSince1970: 0) + + init(duration: TimeInterval, block: @escaping () async -> Void) { + self.duration = duration + self.block = block + } + + func callAsFunction() async { + task?.cancel() + if lastFireTime.timeIntervalSinceNow < -duration { + await fire() + task = nil + } else { + task = Task.detached { [weak self, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await self?.fire() + } + } + } + + func fire() async { + lastFireTime = Date() + await block() + } +} diff --git a/Core/Sources/ConversationTab/ChatContextMenu.swift b/Core/Sources/ConversationTab/ChatContextMenu.swift new file mode 100644 index 0000000..c47f3c4 --- /dev/null +++ b/Core/Sources/ConversationTab/ChatContextMenu.swift @@ -0,0 +1,76 @@ +import AppKit +import ChatService +import ComposableArchitecture +import SharedUIComponents +import SwiftUI + +struct ChatTabItemView: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + Text(chat.title) + } + } +} + +struct ChatContextMenu: View { + let store: StoreOf + @AppStorage(\.customCommands) var customCommands + + var body: some View { + WithPerceptionTracking { + currentSystemPrompt + .onAppear { store.send(.appear) } + currentExtraSystemPrompt + + Divider() + + customCommandMenu + } + } + + @ViewBuilder + var currentSystemPrompt: some View { + Text("System Prompt:") + Text({ + var text = store.systemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + @ViewBuilder + var currentExtraSystemPrompt: some View { + Text("Extra Prompt:") + Text({ + var text = store.extraSystemPrompt + if text.isEmpty { text = "N/A" } + if text.count > 30 { text = String(text.prefix(30)) + "..." } + return text + }() as String) + } + + var customCommandMenu: some View { + Menu("Custom Commands") { + ForEach( + customCommands.filter { + switch $0.feature { + case .chatWithSelection, .customChat: return true + case .promptToCode: return false + case .singleRoundDialog: return false + } + }, + id: \.name + ) { command in + Button(action: { + store.send(.customCommandButtonTapped(command)) + }) { + Text(command.name) + } + } + } + } +} + diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift new file mode 100644 index 0000000..e53730e --- /dev/null +++ b/Core/Sources/ConversationTab/ChatPanel.swift @@ -0,0 +1,625 @@ +import AppKit +import Combine +import ComposableArchitecture +import MarkdownUI +import ChatAPIService +import SharedUIComponents +import SwiftUI +import ChatService + +private let r: Double = 8 + +public struct ChatPanel: View { + let chat: StoreOf + @Namespace var inputAreaNamespace + + public var body: some View { + VStack(spacing: 0) { + ChatPanelMessages(chat: chat) + Divider() + ChatPanelInputArea(chat: chat) + } + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { chat.send(.appear) } + } +} + +private struct ScrollViewOffsetPreferenceKey: PreferenceKey { + static var defaultValue = CGFloat.zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } +} + +private struct ListHeightPreferenceKey: PreferenceKey { + static var defaultValue = CGFloat.zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } +} + +struct ChatPanelMessages: View { + let chat: StoreOf + @State var cancellable = Set() + @State var isScrollToBottomButtonDisplayed = true + @State var isPinnedToBottom = true + @Namespace var bottomID + @Namespace var topID + @Namespace var scrollSpace + @State var scrollOffset: Double = 0 + @State var listHeight: Double = 0 + @State var didScrollToBottomOnAppearOnce = false + @State var isBottomHidden = true + @Environment(\.isEnabled) var isEnabled + + var body: some View { + WithPerceptionTracking { + ScrollViewReader { proxy in + GeometryReader { listGeo in + List { + Group { + Spacer(minLength: 12) + .id(topID) + + Instruction(chat: chat) + + ChatHistory(chat: chat) + .listItemTint(.clear) + + ExtraSpacingInResponding(chat: chat) + + Spacer(minLength: 12) + .id(bottomID) + .onAppear { + isBottomHidden = false + if !didScrollToBottomOnAppearOnce { + proxy.scrollTo(bottomID, anchor: .bottom) + didScrollToBottomOnAppearOnce = true + } + } + .onDisappear { + isBottomHidden = true + } + .background(GeometryReader { geo in + let offset = geo.frame(in: .named(scrollSpace)).minY + Color.clear.preference( + key: ScrollViewOffsetPreferenceKey.self, + value: offset + ) + }) + } + .modify { view in + if #available(macOS 13.0, *) { + view + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) + } else { + view + } + } + } + .listStyle(.plain) + .listRowBackground(EmptyView()) + .modify { view in + if #available(macOS 13.0, *) { + view.scrollContentBackground(.hidden) + } else { + view + } + } + .coordinateSpace(name: scrollSpace) + .preference( + key: ListHeightPreferenceKey.self, + value: listGeo.size.height + ) + .onPreferenceChange(ListHeightPreferenceKey.self) { value in + listHeight = value + updatePinningState() + } + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + updatePinningState() + } + .overlay(alignment: .bottom) { + StopRespondingButton(chat: chat) + } + .overlay(alignment: .bottomTrailing) { + scrollToBottomButton(proxy: proxy) + } + .background { + PinToBottomHandler( + chat: chat, + isBottomHidden: isBottomHidden, + pinnedToBottom: $isPinnedToBottom + ) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(bottomID, anchor: .bottom) + } + .task { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + } + .onAppear { + trackScrollWheel() + } + .onDisappear { + cancellable.forEach { $0.cancel() } + cancellable = [] + } + } + } + + func trackScrollWheel() { + NSApplication.shared.publisher(for: \.currentEvent) + .filter { + if !isEnabled { return false } + return $0?.type == .scrollWheel + } + .compactMap { $0 } + .sink { event in + guard isPinnedToBottom else { return } + let delta = event.deltaY + let scrollUp = delta > 0 + if scrollUp { + isPinnedToBottom = false + } + } + .store(in: &cancellable) + } + + @MainActor + func updatePinningState() { + // where does the 32 come from? + withAnimation(.linear(duration: 0.1)) { + isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20 + || scrollOffset <= 0 + } + } + + @ViewBuilder + func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { + Button(action: { + isPinnedToBottom = true + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(bottomID, anchor: .bottom) + } + }) { + Image(systemName: "arrow.down") + .padding(4) + .background { + Circle() + .fill(.thickMaterial) + .shadow(color: .black.opacity(0.2), radius: 2) + } + .overlay { + Circle().stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + .padding(4) + } + .keyboardShortcut(.downArrow, modifiers: [.command]) + .opacity(isScrollToBottomButtonDisplayed ? 1 : 0) + .buttonStyle(.plain) + } + + struct ExtraSpacingInResponding: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + if chat.isReceivingMessage { + Spacer(minLength: 12) + } + } + } + } + + struct PinToBottomHandler: View { + let chat: StoreOf + let isBottomHidden: Bool + @Binding var pinnedToBottom: Bool + let scrollToBottom: () -> Void + + @State var isInitialLoad = true + + var body: some View { + WithPerceptionTracking { + EmptyView() + .onChange(of: chat.isReceivingMessage) { isReceiving in + if isReceiving { + Task { + pinnedToBottom = true + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + } + } + .onChange(of: chat.history.last) { _ in + if pinnedToBottom || isInitialLoad { + if isInitialLoad { + isInitialLoad = false + } + Task { + await Task.yield() + withAnimation(.easeInOut(duration: 0.1)) { + scrollToBottom() + } + } + } + } + .onChange(of: isBottomHidden) { value in + // This is important to prevent it from jumping to the top! + if value, pinnedToBottom { + scrollToBottom() + } + } + } + } + } +} + +struct ChatHistory: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + ForEach(chat.history, id: \.id) { message in + WithPerceptionTracking { + ChatHistoryItem(chat: chat, message: message).id(message.id) + } + } + } + } +} + +struct ChatHistoryItem: View { + let chat: StoreOf + let message: DisplayedChatMessage + + var body: some View { + WithPerceptionTracking { + 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( + id: message.id, + text: text, + references: message.references, + chat: chat + ) + .listRowInsets(EdgeInsets( + top: 0, + leading: -8, + bottom: 0, + trailing: -8 + )) + .padding(.vertical, 4) + case .tool: + FunctionMessage(id: message.id, text: text) + case .ignored: + EmptyView() + } + } + } +} + +private struct StopRespondingButton: View { + let chat: StoreOf + + var body: some View { + WithPerceptionTracking { + if chat.isReceivingMessage { + Button(action: { + chat.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop Responding") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: r, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: r, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .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 + )) + } + } + } +} + +struct ChatPanelInputArea: View { + let chat: StoreOf + @FocusState var focusedField: Chat.State.Field? + + 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) + + Button(action: { + chat.send(.sendButtonTapped(UUID().uuidString)) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .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() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + + Button(action: { + focusedField.wrappedValue = .textField + }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } + } + } + + 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 + } + } +} + +// MARK: - Previews + +struct ChatPanel_Preview: PreviewProvider { + static let history: [DisplayedChatMessage] = [ + .init( + id: "1", + role: .user, + text: "**Hello**", + references: [] + ), + .init( + id: "2", + role: .assistant, + text: """ + ```swift + func foo() {} + ``` + **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? + """, + references: [ + .init( + title: "Hello Hello Hello Hello", + subtitle: "Hi Hi Hi Hi", + uri: "https://google.com", + startLine: nil, + kind: .class + ), + ] + ), + .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: [] + ), + .init( + id: "5", + role: .assistant, + text: "Yooo", + references: [] + ), + .init( + id: "4", + role: .user, + text: "Yeeeehh", + references: [] + ), + .init( + id: "3", + role: .user, + text: #""" + Please buy me a coffee! + | Coffee | Milk | + |--------|------| + | Espresso | No | + | Latte | Yes | + + ```swift + func foo() {} + ``` + ```objectivec + - (void)bar {} + ``` + """#, + references: [] + ), + ] + + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: ChatPanel_Preview.history, isReceivingMessage: true), + reducer: { Chat(service: ChatService.service()) } + )) + .frame(width: 450, height: 1200) + .colorScheme(.dark) + } +} + +struct ChatPanel_EmptyChat_Preview: PreviewProvider { + static var previews: some View { + ChatPanel(chat: .init( + initialState: .init(history: [DisplayedChatMessage](), isReceivingMessage: false), + reducer: { Chat(service: ChatService.service()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +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()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +struct ChatPanel_InputMultilineText_Preview: PreviewProvider { + static var previews: some View { + 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.", + + history: ChatPanel_Preview.history, + isReceivingMessage: false + ), + reducer: { Chat(service: ChatService.service()) } + ) + ) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.dark) + } +} + +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()) } + )) + .padding() + .frame(width: 450, height: 600) + .colorScheme(.light) + } +} + diff --git a/Core/Sources/ConversationTab/CodeBlockHighlighter.swift b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift new file mode 100644 index 0000000..cfbde1c --- /dev/null +++ b/Core/Sources/ConversationTab/CodeBlockHighlighter.swift @@ -0,0 +1,117 @@ +import Combine +import ComposableArchitecture +import DebounceFunction +import Foundation +import MarkdownUI +import Perception +import SharedUIComponents +import SwiftUI + +/// Use this instead of the built in ``CodeBlockView`` to highlight code blocks asynchronously, +/// so that the UI doesn't freeze when rendering large code blocks. +struct AsyncCodeBlockView: View { + @Perceptible + class Storage { + static let queue = DispatchQueue( + label: "chat-code-block-highlight", + qos: .userInteractive, + attributes: .concurrent + ) + + var highlighted: AttributedString? + @PerceptionIgnored var debounceFunction: DebounceFunction? + @PerceptionIgnored private var highlightTask: Task? + + init() { + debounceFunction = .init(duration: 0.5, block: { [weak self] view in + self?.highlight(for: view) + }) + } + + func highlight(debounce: Bool, for view: AsyncCodeBlockView) { + if debounce { + Task { await debounceFunction?(view) } + } else { + highlight(for: view) + } + } + + func highlight(for view: AsyncCodeBlockView) { + highlightTask?.cancel() + let content = view.content + let language = view.fenceInfo ?? "" + let brightMode = view.colorScheme != .dark + let font = view.font + highlightTask = Task { + let string = await withUnsafeContinuation { continuation in + Self.queue.async { + let content = CodeHighlighting.highlightedCodeBlock( + code: content, + language: language, + scenario: "chat", + brightMode: brightMode, + font: font + ) + continuation.resume(returning: AttributedString(content)) + } + } + try Task.checkCancellation() + await MainActor.run { + self.highlighted = string + } + } + } + } + + let fenceInfo: String? + let content: String + let font: NSFont + + @Environment(\.colorScheme) var colorScheme + @State var storage = Storage() + @AppStorage(\.syncChatCodeHighlightTheme) var syncCodeHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + + init(fenceInfo: String?, content: String, font: NSFont) { + self.fenceInfo = fenceInfo + self.content = content.hasSuffix("\n") ? String(content.dropLast()) : content + self.font = font + } + + var body: some View { + WithPerceptionTracking { + Group { + if let highlighted = storage.highlighted { + Text(highlighted) + } else { + Text(content).font(.init(font)) + } + } + .onAppear { + storage.highlight(debounce: false, for: self) + } + .onChange(of: colorScheme) { _ in + storage.highlight(debounce: false, for: self) + } + .onChange(of: syncCodeHighlightTheme) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorLight) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeForegroundColorDark) { _ in + storage.highlight(debounce: true, for: self) + } + .onChange(of: codeBackgroundColorDark) { _ in + storage.highlight(debounce: true, for: self) + } + } + } +} + diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift new file mode 100644 index 0000000..440c91f --- /dev/null +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -0,0 +1,148 @@ +import ChatService +import ChatTab +import CodableWrappers +import Combine +import ComposableArchitecture +import DebounceFunction +import Foundation +import ChatAPIService +import Preferences +import SwiftUI + +/// 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 + let chat: StoreOf + private var cancellable = Set() + private var observer = NSObject() + private let updateContentDebounce = DebounceRunner(duration: 0.5) + + struct RestorableState: Codable { + var history: [ChatAPIService.ChatMessage] + } + + struct Builder: ChatTabBuilder { + var title: String + var customCommand: CustomCommand? + var afterBuild: (ConversationTab) async -> Void = { _ in } + + func build(store: StoreOf) async -> (any ChatTab)? { + let tab = await ConversationTab(store: store) + if let customCommand { + try? await tab.service.handleCustomCommand(customCommand) + } + await afterBuild(tab) + return tab + } + } + + public func buildView() -> any View { + ChatPanel(chat: chat) + } + + public func buildTabItem() -> any View { + ChatTabItemView(chat: chat) + } + + public func buildIcon() -> any View { + WithPerceptionTracking { + if self.chat.isReceivingMessage { + Image(systemName: "ellipsis.message") + } else { + Image(systemName: "message") + } + } + } + + public func buildMenu() -> any View { + ChatContextMenu(store: chat.scope(state: \.chatMenu, action: \.chatMenu)) + } + + public func restorableState() async -> Data { + let state = RestorableState( + history: await service.memory.history + ) + return (try? JSONEncoder().encode(state)) ?? Data() + } + + public static func restore( + from data: Data, + externalDependency: Void + ) async throws -> any ChatTabBuilder { + let state = try JSONDecoder().decode(RestorableState.self, from: data) + let builder = Builder(title: "Chat") { @MainActor tab in + await tab.service.memory.mutateHistory { history in + history = state.history + } + tab.chat.send(.refresh) + } + return builder + } + + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + let customCommands = UserDefaults.shared.value(for: \.customCommands).compactMap { + command in + if case .customChat = command.feature { + return Builder(title: command.name, customCommand: command) + } + return nil + } + + return [Builder(title: "New Chat", customCommand: nil)] + customCommands + } + + @MainActor + public init(service: ChatService = ChatService.service(), store: StoreOf) { + self.service = service + chat = .init(initialState: .init(), reducer: { Chat(service: service) }) + super.init(store: store) + } + + public func start() { + observer = .init() + cancellable = [] + + 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) + } + } + } + + 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)) + } + } + } + + 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) + } + } + } + } +} + diff --git a/Core/Sources/ConversationTab/Styles.swift b/Core/Sources/ConversationTab/Styles.swift new file mode 100644 index 0000000..6c117c9 --- /dev/null +++ b/Core/Sources/ConversationTab/Styles.swift @@ -0,0 +1,167 @@ +import AppKit +import MarkdownUI +import SharedUIComponents +import SwiftUI + +extension Color { + static var contentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1) + } + return #colorLiteral(red: 0.9896564803, green: 0.9896564803, blue: 0.9896564803, alpha: 1) + })) + } + + static var userChatContentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1) + } + return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1) + })) + } +} + +extension NSAppearance { + var isDarkMode: Bool { + if bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { + return true + } else { + return false + } + } +} + +extension View { + var messageBubbleCornerRadius: Double { 8 } + + func codeBlockLabelStyle() -> some View { + relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .padding(.top, 14) + } + + func codeBlockStyle( + _ configuration: CodeBlockConfiguration, + backgroundColor: Color, + labelColor: Color + ) -> some View { + background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .top) { + HStack(alignment: .center) { + Text(configuration.language ?? "code") + .foregroundStyle(labelColor) + .font(.callout.bold()) + .padding(.leading, 8) + .lineLimit(1) + Spacer() + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + } + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.05), lineWidth: 1) + } + .markdownMargin(top: 4, bottom: 16) + } +} + +final class VerticalScrollingFixHostingView: NSHostingView where Content: View { + override func wantsForwardedScrollEvents(for axis: NSEvent.GestureAxis) -> Bool { + return axis == .vertical + } +} + +struct VerticalScrollingFixViewRepresentable: NSViewRepresentable where Content: View { + let content: Content + + func makeNSView(context: Context) -> NSHostingView { + return VerticalScrollingFixHostingView(rootView: content) + } + + func updateNSView(_ nsView: NSHostingView, context: Context) {} +} + +struct VerticalScrollingFixWrapper: View where Content: View { + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + VerticalScrollingFixViewRepresentable(content: self.content()) + } +} + +extension View { + /// https://stackoverflow.com/questions/64920744/swiftui-nested-scrollviews-problem-on-macos + @ViewBuilder func workaroundForVerticalScrollingBugInMacOS() -> some View { + VerticalScrollingFixWrapper { self } + } +} + +struct RoundedCorners: Shape { + var tl: CGFloat = 0.0 + var tr: CGFloat = 0.0 + var bl: CGFloat = 0.0 + var br: CGFloat = 0.0 + + func path(in rect: CGRect) -> Path { + Path { path in + + let w = rect.size.width + let h = rect.size.height + + // Make sure we do not exceed the size of the rectangle + let tr = min(min(self.tr, h / 2), w / 2) + let tl = min(min(self.tl, h / 2), w / 2) + let bl = min(min(self.bl, h / 2), w / 2) + let br = min(min(self.br, h / 2), w / 2) + + path.move(to: CGPoint(x: w / 2.0, y: 0)) + path.addLine(to: CGPoint(x: w - tr, y: 0)) + path.addArc( + center: CGPoint(x: w - tr, y: tr), + radius: tr, + startAngle: Angle(degrees: -90), + endAngle: Angle(degrees: 0), + clockwise: false + ) + path.addLine(to: CGPoint(x: w, y: h - br)) + path.addArc( + center: CGPoint(x: w - br, y: h - br), + radius: br, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: 90), + clockwise: false + ) + path.addLine(to: CGPoint(x: bl, y: h)) + path.addArc( + center: CGPoint(x: bl, y: h - bl), + radius: bl, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 180), + clockwise: false + ) + path.addLine(to: CGPoint(x: 0, y: tl)) + path.addArc( + center: CGPoint(x: tl, y: tl), + radius: tl, + startAngle: Angle(degrees: 180), + endAngle: Angle(degrees: 270), + clockwise: false + ) + path.closeSubpath() + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift new file mode 100644 index 0000000..d71fc31 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/BotMessage.swift @@ -0,0 +1,292 @@ +import ComposableArchitecture +import ChatService +import Foundation +import MarkdownUI +import SharedUIComponents +import SwiftUI + +struct BotMessage: View { + var r: Double { messageBubbleCornerRadius } + let id: String + let text: String + let references: [DisplayedChatMessage.Reference] + let chat: StoreOf + @Environment(\.colorScheme) var colorScheme + + @State var isReferencesPresented = false + @State var isReferencesHovered = false + + 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)) + } + + DownvoteButton { rating in + chat.send(.downvote(id, rating)) + } + + 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)) + } + .overlay { + RoundedRectangle(cornerRadius: r - 4) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .foregroundStyle(.secondary) + }) + .buttonStyle(.plain) + .popover(isPresented: $isReferencesPresented, arrowEdge: .trailing) { + ReferenceList(references: references, chat: chat) + } + } + + 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) + } + + Button("Set as Extra System Prompt") { + chat.send(.setAsExtraPromptButtonTapped(id)) + } + + Divider() + + Button("Delete") { + chat.send(.deleteMessageButtonTapped(id)) + } + } + } + .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.. MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) + } + } +} diff --git a/Core/Sources/ConversationTab/Views/FunctionMessage.swift b/Core/Sources/ConversationTab/Views/FunctionMessage.swift new file mode 100644 index 0000000..3e6b031 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/FunctionMessage.swift @@ -0,0 +1,30 @@ +import Foundation +import MarkdownUI +import SwiftUI + +struct FunctionMessage: View { + let id: String + let text: String + @AppStorage(\.chatFontSize) var chatFontSize + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.functionCall(fontSize: chatFontSize)) + .padding(.vertical, 2) + .padding(.trailing, 2) + } +} + +#Preview { + FunctionMessage(id: "1", text: """ + Searching for something... + - abc + - [def](https://1.com) + > hello + > hi + """) + .padding() + .fixedSize() +} + diff --git a/Core/Sources/ConversationTab/Views/InstructionMarkdownTheme.swift b/Core/Sources/ConversationTab/Views/InstructionMarkdownTheme.swift new file mode 100644 index 0000000..30e786e --- /dev/null +++ b/Core/Sources/ConversationTab/Views/InstructionMarkdownTheme.swift @@ -0,0 +1,68 @@ +import Foundation +import MarkdownUI +import SwiftUI + +extension MarkdownUI.Theme { + static func instruction(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + BackgroundColor(Color.secondary.opacity(0.2)) + } + .codeBlock { configuration in + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + + if wrapCode { + configuration.label + .codeBlockLabelStyle() + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) + } else { + ScrollView(.horizontal) { + configuration.label + .codeBlockLabelStyle() + } + .workaroundForVerticalScrollingBugInMacOS() + .codeBlockStyle( + configuration, + backgroundColor: Color(nsColor: .textBackgroundColor).opacity(0.7), + labelColor: Color.secondary.opacity(0.7) + ) + } + } + .table { configuration in + configuration.label + .fixedSize(horizontal: false, vertical: true) + .markdownTableBorderStyle(.init( + color: .init(nsColor: .separatorColor), + strokeStyle: .init(lineWidth: 1) + )) + .markdownTableBackgroundStyle( + .alternatingRows(Color.secondary.opacity(0.1), Color.secondary.opacity(0.2)) + ) + .markdownMargin(top: 0, bottom: 16) + } + .tableCell { configuration in + configuration.label + .markdownTextStyle { + if configuration.row == 0 { + FontWeight(.semibold) + } + BackgroundColor(nil) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 6) + .padding(.horizontal, 13) + .relativeLineSpacing(.em(0.25)) + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/Instructions.swift b/Core/Sources/ConversationTab/Views/Instructions.swift new file mode 100644 index 0000000..8ee892c --- /dev/null +++ b/Core/Sources/ConversationTab/Views/Instructions.swift @@ -0,0 +1,39 @@ +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/ThemedMarkdownText.swift b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift new file mode 100644 index 0000000..eca57a2 --- /dev/null +++ b/Core/Sources/ConversationTab/Views/ThemedMarkdownText.swift @@ -0,0 +1,102 @@ +import Foundation +import MarkdownUI +import SwiftUI + +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 + + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Markdown(text) + .textSelection(.enabled) + .markdownTheme(.custom( + fontSize: chatFontSize, + codeFont: chatCodeFont.value.nsFont, + codeBlockBackgroundColor: { + if syncCodeHighlightTheme { + 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) + }(), + codeBlockLabelColor: { + if syncCodeHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value + { + return color.swiftUIColor.opacity(0.5) + } else if let color = codeForegroundColorDark.value { + return color.swiftUIColor.opacity(0.5) + } + } + return Color.secondary.opacity(0.7) + }() + )) + } +} + +// MARK: - Theme + +extension MarkdownUI.Theme { + static func custom( + fontSize: Double, + codeFont: NSFont, + codeBlockBackgroundColor: Color, + codeBlockLabelColor: Color + ) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .codeBlock { configuration in + let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) + + if wrapCode { + AsyncCodeBlockView( + fenceInfo: configuration.language, + content: configuration.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 + ) + } + } + } +} + diff --git a/Core/Sources/ConversationTab/Views/UserMessage.swift b/Core/Sources/ConversationTab/Views/UserMessage.swift new file mode 100644 index 0000000..e6917bb --- /dev/null +++ b/Core/Sources/ConversationTab/Views/UserMessage.swift @@ -0,0 +1,62 @@ +import ComposableArchitecture +import ChatService +import Foundation +import MarkdownUI +import SharedUIComponents +import SwiftUI + +struct UserMessage: View { + var r: Double { messageBubbleCornerRadius } + let id: String + let text: String + let chat: StoreOf + @Environment(\.colorScheme) var colorScheme + + 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) + } + .shadow(color: .black.opacity(0.05), radius: 6) + } + .padding(.leading, 8) + .padding(.trailing, 8) + } +} + +#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()) } + ) + ) + .padding() + .fixedSize(horizontal: true, vertical: true) +} + diff --git a/Core/Sources/FileChangeChecker/FileChangeChecker.swift b/Core/Sources/FileChangeChecker/FileChangeChecker.swift new file mode 100644 index 0000000..a844404 --- /dev/null +++ b/Core/Sources/FileChangeChecker/FileChangeChecker.swift @@ -0,0 +1,39 @@ +import CryptoKit +import Dispatch +import Foundation + +/// Check that a file is changed. +public actor FileChangeChecker { + let url: URL + var checksum: Data? + + public init(fileURL: URL) async { + url = fileURL + checksum = getChecksum() + } + + public func checkIfChanged() -> Bool { + guard let newChecksum = getChecksum() else { return false } + return newChecksum != checksum + } + + func getChecksum() -> Data? { + let bufferSize = 16 * 1024 + guard let file = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? file.close() } + var md5 = CryptoKit.Insecure.MD5() + while autoreleasepool(invoking: { + let data = file.readData(ofLength: bufferSize) + if !data.isEmpty { + md5.update(data: data) + return true // Continue + } else { + return false // End of file + } + }) {} + + let data = Data(md5.finalize()) + + return data + } +} diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift new file mode 100644 index 0000000..e641760 --- /dev/null +++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotView.swift @@ -0,0 +1,267 @@ +import AppKit +import Client +import GitHubCopilotService +import Preferences +import SharedUIComponents +import SuggestionBasic +import SwiftUI + +struct SignInResponse { + let userCode: String + let verificationURL: URL +} + +struct GitHubCopilotView: View { + static var copilotAuthService: GitHubCopilotAuthServiceType? + + class Settings: ObservableObject { + @AppStorage("username") var username: String = "" + @AppStorage(\.disableGitHubCopilotSettingsAutoRefreshOnAppear) + var disableGitHubCopilotSettingsAutoRefreshOnAppear + init() {} + } + + @Environment(\.openURL) var openURL + @Environment(\.toast) var toast + @StateObject var settings = Settings() + + @State var status: GitHubCopilotAccountStatus? + @State var signInResponse: SignInResponse? + @State var version: String? + @State var isRunningAction: Bool = false + @State var isSignInAlertPresented = false + @State var xcodeBetaAccessAlert = false + @State var waitingForSignIn = false + + func getGitHubCopilotAuthService() throws -> GitHubCopilotAuthServiceType { + if let service = Self.copilotAuthService { return service } + let service = try GitHubCopilotService() + Self.copilotAuthService = service + return service + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text("Language Server Version: \(version ?? "Loading..")") + .alert(isPresented: $xcodeBetaAccessAlert) { + Alert( + title: Text("Xcode Beta Access Not Granted"), + message: Text( + "Logged in user does not have access to GitHub Copilot for Xcode" + ), + dismissButton: .default(Text("Close")) + ) + } + + if waitingForSignIn { + Text("Status: Waiting for GitHub authentication") + } else { + Text(""" + Status: \(status?.description ?? "Loading..")\ + \(xcodeBetaAccessAlert ? " - Xcode Beta Access Not Granted" : "") + """) + } + + HStack(alignment: .center) { + Button("Refresh") { + checkStatus() + } + if waitingForSignIn { + Button("Cancel") { cancelWaiting() } + } else if status == .notSignedIn { + Button("Sign In") { signIn() } + .alert( + signInResponse?.userCode ?? "", + isPresented: $isSignInAlertPresented, + presenting: signInResponse) { _ in + Button("Cancel", role: .cancel, action: {}) + Button("Copy Code and Open", action: copyAndOpen) + } 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 ?? "") + """) + } + } + if status == .ok || status == .alreadySignedIn || + status == .notAuthorized + { + Button("Sign Out") { signOut() } + } + if isRunningAction || waitingForSignIn { + ActivityIndicatorView() + } + } + .opacity(isRunningAction ? 0.8 : 1) + .disabled(isRunningAction) + } + .padding() + + Spacer() + } + .onAppear { + if isPreview { return } + if settings.disableGitHubCopilotSettingsAutoRefreshOnAppear { return } + checkStatus() + } + .textFieldStyle(.roundedBorder) + .onReceive(FeatureFlagNotifierImpl.shared.featureFlagsDidChange) { flags in + self.xcodeBetaAccessAlert = flags.x != true + } + } + + 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 + + if status != .ok, status != .notSignedIn { + toast( + "GitHub Copilot status is not \"ok\". Please check if you have a valid GitHub Copilot subscription.", + + .error + ) + } + } catch { + toast(error.localizedDescription, .error) + } + } + } + + 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) + isSignInAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + } + + func copyAndOpen() { + waitingForSignIn = true + guard let signInResponse else { + toast("Missing sign in details.", .error) + return + } + // Copy the device code to the clipboard + 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) + // Open verification URL in default browser + openURL(signInResponse.verificationURL) + // Wait for signInConfirm response + 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.settings.username = username + self.status = status + } catch let error as GitHubCopilotError { + if case .languageServerError(.timeout) = error { + // TODO figure out how to extend the default timeout on an LSP request + // Until then, reissue request + waitForSignIn() + return + } + throw error + } catch { + toast(error.localizedDescription, .error) + } + } + + } + + func cancelWaiting() { + waitingForSignIn = false + } + + func signOut() { + Task { + isRunningAction = true + defer { isRunningAction = false } + do { + let service = try getGitHubCopilotAuthService() + status = try await service.signOut() + } catch { + toast(error.localizedDescription, .error) + } + } + } + + func refreshConfiguration() { + NotificationCenter.default.post( + name: .gitHubCopilotShouldRefreshEditorInformation, + object: nil + ) + + Task { + let service = try getService() + do { + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } +} + +struct ActivityIndicatorView: NSViewRepresentable { + func makeNSView(context _: Context) -> NSProgressIndicator { + let progressIndicator = NSProgressIndicator() + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.startAnimation(nil) + return progressIndicator + } + + func updateNSView(_: NSProgressIndicator, context _: Context) { + // No-op + } +} + +struct CopilotView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading, spacing: 8) { + GitHubCopilotView(status: .notSignedIn, version: "1.0.0") + GitHubCopilotView(status: .alreadySignedIn, isRunningAction: true) + GitHubCopilotView(settings: .init(), status: .alreadySignedIn, xcodeBetaAccessAlert: true) + GitHubCopilotView(settings: .init(), status: .notSignedIn, waitingForSignIn: true) + } + .padding(.all, 8) + .previewLayout(.sizeThatFits) + } +} + diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift new file mode 100644 index 0000000..caa217b --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggesionSettingProxyView.swift @@ -0,0 +1,88 @@ +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared +import Toast +import Client + +struct SuggesionSettingProxyView: View { + + class Settings: ObservableObject { + @AppStorage("username") var username: String = "" + @AppStorage(\.gitHubCopilotProxyHost) var gitHubCopilotProxyHost + @AppStorage(\.gitHubCopilotProxyPort) var gitHubCopilotProxyPort + @AppStorage(\.gitHubCopilotProxyUsername) var gitHubCopilotProxyUsername + @AppStorage(\.gitHubCopilotProxyPassword) var gitHubCopilotProxyPassword + @AppStorage(\.gitHubCopilotUseStrictSSL) var gitHubCopilotUseStrictSSL + @AppStorage(\.gitHubCopilotEnterpriseURI) var gitHubCopilotEnterpriseURI + + init() {} + } + + @StateObject var settings = Settings() + @Environment(\.toast) var toast + + var body: some View { + VStack(alignment: .leading) { + SettingsDivider("Enterprise") + + Form { + TextField( + text: $settings.gitHubCopilotEnterpriseURI, + prompt: Text("Leave it blank if none is available.") + ) { + Text("Auth provider URL") + } + } + + SettingsDivider("Proxy") + + Form { + TextField( + text: $settings.gitHubCopilotProxyHost, + prompt: Text("xxx.xxx.xxx.xxx, leave it blank to disable proxy.") + ) { + Text("Proxy host") + } + TextField(text: $settings.gitHubCopilotProxyPort, prompt: Text("80")) { + Text("Proxy port") + } + TextField(text: $settings.gitHubCopilotProxyUsername) { + Text("Proxy username") + } + SecureField(text: $settings.gitHubCopilotProxyPassword) { + Text("Proxy password") + } + Toggle("Proxy strict SSL", isOn: $settings.gitHubCopilotUseStrictSSL) + + Button("Refresh configurations") { + refreshConfiguration() + }.padding(.top, 6) + } + } + .textFieldStyle(.roundedBorder) + } + + func refreshConfiguration() { + NotificationCenter.default.post( + name: .gitHubCopilotShouldRefreshEditorInformation, + object: nil + ) + + Task { + let service = try getService() + do { + try await service.postNotification( + name: Notification.Name + .gitHubCopilotShouldRefreshEditorInformation.rawValue + ) + } catch { + toast(error.localizedDescription, .error) + } + } + } +} + +#Preview { + SuggesionSettingProxyView() +} diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift new file mode 100644 index 0000000..fa3074e --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -0,0 +1,115 @@ +import SuggestionBasic +import SwiftUI +import SharedUIComponents + +extension List { + @ViewBuilder + func removeBackground() -> some View { + if #available(macOS 13.0, *) { + scrollContentBackground(.hidden) + .listRowBackground(EmptyView()) + } else { + background(Color.clear) + .listRowBackground(EmptyView()) + } + } +} + +struct SuggestionFeatureDisabledLanguageListView: View { + final class Settings: ObservableObject { + @AppStorage(\.suggestionFeatureDisabledLanguageList) + var suggestionFeatureDisabledLanguageList: [String] + + init(suggestionFeatureDisabledLanguageList: AppStorage<[String]>? = nil) { + if let list = suggestionFeatureDisabledLanguageList { + _suggestionFeatureDisabledLanguageList = list + } + } + } + + var isOpen: Binding + @State var isAddingNewProject = false + @StateObject var settings = Settings() + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Disabled Languages") + Spacer() + } + .background(Color(nsColor: .separatorColor)) + + List { + ForEach( + settings.suggestionFeatureDisabledLanguageList, + id: \.self + ) { language in + HStack { + Text(language.capitalized) + .contextMenu { + Button("Remove") { + settings.suggestionFeatureDisabledLanguageList.removeAll( + where: { $0 == language } + ) + } + } + Spacer() + + Button(action: { + settings.suggestionFeatureDisabledLanguageList.removeAll( + where: { $0 == language } + ) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + } + .removeBackground() + .overlay { + if settings.suggestionFeatureDisabledLanguageList.isEmpty { + Text(""" + Empty + Disable the language of a file by right clicking the circular widget. + """) + .multilineTextAlignment(.center) + .padding() + } + } + } + .focusable(false) + .frame(width: 300, height: 400) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +struct SuggestionFeatureDisabledLanguageListView_Preview: PreviewProvider { + static var previews: some View { + SuggestionFeatureDisabledLanguageListView( + isOpen: .constant(true), + settings: .init(suggestionFeatureDisabledLanguageList: .init(wrappedValue: [ + "hello/2", + "hello/3", + "hello/4", + ], "SuggestionFeatureDisabledLanguageListView_Preview")) + ) + } +} + diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift new file mode 100644 index 0000000..f57cd5e --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift @@ -0,0 +1,140 @@ +import SharedUIComponents +import SwiftUI + +struct SuggestionFeatureEnabledProjectListView: View { + final class Settings: ObservableObject { + @AppStorage(\.suggestionFeatureEnabledProjectList) + var suggestionFeatureEnabledProjectList: [String] + + init(suggestionFeatureEnabledProjectList: AppStorage<[String]>? = nil) { + if let list = suggestionFeatureEnabledProjectList { + _suggestionFeatureEnabledProjectList = list + } + } + } + + var isOpen: Binding + @State var isAddingNewProject = false + @StateObject var settings = Settings() + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: { + self.isOpen.wrappedValue = false + }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + Text("Enabled Projects") + Spacer() + Button(action: { + isAddingNewProject = true + }) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) + .padding() + } + .buttonStyle(.plain) + } + .background(Color(nsColor: .separatorColor)) + + List { + ForEach( + settings.suggestionFeatureEnabledProjectList, + id: \.self + ) { project in + HStack { + Text(project) + .contextMenu { + Button("Remove") { + settings.suggestionFeatureEnabledProjectList.removeAll( + where: { $0 == project } + ) + } + } + Spacer() + + Button(action: { + settings.suggestionFeatureEnabledProjectList.removeAll( + where: { $0 == project } + ) + }) { + Image(systemName: "trash.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + } + .removeBackground() + .overlay { + if settings.suggestionFeatureEnabledProjectList.isEmpty { + Text(""" + Empty + Add project with "+" button + Or right clicking the circular widget + """) + .multilineTextAlignment(.center) + } + } + } + .focusable(false) + .frame(width: 300, height: 400) + .background(Color(nsColor: .windowBackgroundColor)) + .sheet(isPresented: $isAddingNewProject) { + SuggestionFeatureAddEnabledProjectView(isOpen: $isAddingNewProject, settings: settings) + } + } +} + +struct SuggestionFeatureAddEnabledProjectView: View { + var isOpen: Binding + var settings: SuggestionFeatureEnabledProjectListView.Settings + @State var rootPath = "" + + var body: some View { + VStack { + Text( + "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName." + ) + TextField("Root path", text: $rootPath) + HStack { + Spacer() + Button("Cancel") { + isOpen.wrappedValue = false + } + Button("Add") { + settings.suggestionFeatureEnabledProjectList.append(rootPath) + isOpen.wrappedValue = false + } + } + } + .padding() + .frame(minWidth: 500) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +struct SuggestionFeatureEnabledProjectListView_Preview: PreviewProvider { + static var previews: some View { + SuggestionFeatureEnabledProjectListView( + isOpen: .constant(true), + settings: .init(suggestionFeatureEnabledProjectList: .init(wrappedValue: [ + "hello/2", + "hello/3", + "hello/4", + ], "SuggestionFeatureEnabledProjectListView_Preview")) + ) + } +} + diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift new file mode 100644 index 0000000..35f7caa --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -0,0 +1,49 @@ +import Client +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared + +struct SuggestionSettingsGeneralSectionView: View { + final class Settings: ObservableObject { + @AppStorage(\.realtimeSuggestionToggle) + var realtimeSuggestionToggle + @AppStorage(\.suggestionFeatureEnabledProjectList) + var suggestionFeatureEnabledProjectList + @AppStorage(\.acceptSuggestionWithTab) + var acceptSuggestionWithTab + } + + @StateObject var settings = Settings() + @State var isSuggestionFeatureDisabledLanguageListViewOpen = false + + var body: some View { + Form { + Toggle(isOn: $settings.realtimeSuggestionToggle) { + Text("Request suggestions in real-time") + } + + Toggle(isOn: $settings.acceptSuggestionWithTab) { + HStack { + Text("Accept suggestions with Tab") + } + } + + HStack { + Button("Disabled language list") { + isSuggestionFeatureDisabledLanguageListViewOpen = true + } + }.sheet(isPresented: $isSuggestionFeatureDisabledLanguageListViewOpen) { + SuggestionFeatureDisabledLanguageListView( + isOpen: $isSuggestionFeatureDisabledLanguageListViewOpen + ) + } + } + } +} + +#Preview { + SuggestionSettingsGeneralSectionView() + .padding() +} + diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift new file mode 100644 index 0000000..f5a99ac --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift @@ -0,0 +1,22 @@ +import Client +import Preferences +import SharedUIComponents +import SwiftUI +import XPCShared + +struct SuggestionSettingsView: View { + var body: some View { + ScrollView { + SuggestionSettingsGeneralSectionView() + SuggesionSettingProxyView() + }.padding() + } +} + +struct SuggestionSettingsView_Previews: PreviewProvider { + static var previews: some View { + SuggestionSettingsView() + .frame(width: 600, height: 500) + } +} + diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift new file mode 100644 index 0000000..bee029b --- /dev/null +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct FeatureSettingsView: View { + var body: some View { + SuggestionSettingsView() + .sidebarItem( + tag: 0, + title: "Suggestion", + subtitle: "Generate suggestions for your code", + image: "lightbulb" + ) + } +} + +struct FeatureSettingsView_Previews: PreviewProvider { + static var previews: some View { + FeatureSettingsView() + .frame(width: 800) + } +} + diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift new file mode 100644 index 0000000..53d37b8 --- /dev/null +++ b/Core/Sources/HostApp/General.swift @@ -0,0 +1,116 @@ +import Client +import ComposableArchitecture +import Foundation +import LaunchAgentManager +import SwiftUI +import XPCShared + +@Reducer +struct General { + @ObservableState + struct State: Equatable { + var xpcServiceVersion: String? + var isAccessibilityPermissionGranted: Bool? + var isReloading = false + } + + enum Action: Equatable { + case appear + case setupLaunchAgentIfNeeded + case openExtensionManager + case reloadStatus + case finishReloading(xpcServiceVersion: String, permissionGranted: Bool) + case failedReloading + } + + @Dependency(\.toast) var toast + + struct ReloadStatusCancellableId: Hashable {} + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .appear: + return .run { send in + Task { + for await _ in DistributedNotificationCenter.default().notifications(named: NSNotification.Name("com.apple.accessibility.api")) { + await send(.reloadStatus) + } + } + await send(.setupLaunchAgentIfNeeded) + } + + case .setupLaunchAgentIfNeeded: + return .run { send in + #if DEBUG + // do not auto install on debug build + #else + Task { + do { + try await LaunchAgentManager() + .setupLaunchAgentForTheFirstTimeIfNeeded() + } catch { + toast(error.localizedDescription, .error) + } + } + #endif + await send(.reloadStatus) + } + + case .openExtensionManager: + return .run { send in + let service = try getService() + do { + _ = try await service + .send(requestBody: ExtensionServiceRequests.OpenExtensionManager()) + } catch { + toast(error.localizedDescription, .error) + await send(.failedReloading) + } + } + + case .reloadStatus: + state.isReloading = true + return .run { send in + let service = try getService() + do { + let isCommunicationReady = try await service.launchIfNeeded() + if isCommunicationReady { + let xpcServiceVersion = try await service.getXPCServiceVersion().version + let isAccessibilityPermissionGranted = try await service + .getXPCServiceAccessibilityPermission() + await send(.finishReloading( + xpcServiceVersion: xpcServiceVersion, + permissionGranted: isAccessibilityPermissionGranted + )) + } else { + toast("Launching service app.", .info) + try await Task.sleep(nanoseconds: 5_000_000_000) + await send(.reloadStatus) + } + } catch let error as XPCCommunicationBridgeError { + toast( + "Failed to reach communication bridge. \(error.localizedDescription)", + .error + ) + await send(.failedReloading) + } catch { + toast(error.localizedDescription, .error) + await send(.failedReloading) + } + }.cancellable(id: ReloadStatusCancellableId(), cancelInFlight: true) + + case let .finishReloading(version, granted): + state.xpcServiceVersion = version + state.isAccessibilityPermissionGranted = granted + state.isReloading = false + return .none + + case .failedReloading: + state.isReloading = false + return .none + } + } + } +} + diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift new file mode 100644 index 0000000..d865c15 --- /dev/null +++ b/Core/Sources/HostApp/GeneralView.swift @@ -0,0 +1,244 @@ +import Client +import ComposableArchitecture +import KeyboardShortcuts +import LaunchAgentManager +import Preferences +import SharedUIComponents +import SwiftUI + +struct GeneralView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + AppInfoView(store: store) + SettingsDivider() + GitHubCopilotView() + SettingsDivider() + ExtensionServiceView(store: store) + SettingsDivider() + LaunchAgentView() + SettingsDivider() + GeneralSettingsView() + } + } + .onAppear { + store.send(.appear) + } + } +} + +struct AppInfoView: View { + @State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + @Environment(\.updateChecker) var updateChecker + let store: StoreOf + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text( + Bundle.main + .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String + ?? "GitHub Copilot for Xcode" + ) + .font(.title) + Text(appVersion ?? "") + .font(.footnote) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { + updateChecker.checkForUpdates() + }) { + HStack(spacing: 2) { + Image(systemName: "arrow.up.right.circle.fill") + Text("Check for Updates") + } + } + } + }.padding() + } +} + +struct ExtensionServiceView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")") + + let grantedStatus: String = { + guard let granted = store.isAccessibilityPermissionGranted + else { return "Loading.." } + return granted ? "Granted" : "Not Granted" + }() + Text("Accessibility Permission: \(grantedStatus)") + + HStack { + Button(action: { store.send(.reloadStatus) }) { + Text("Refresh") + }.disabled(store.isReloading) + + Button(action: { + Task { + let workspace = NSWorkspace.shared + let url = Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Applications") + .appendingPathComponent("GitHub Copilot for Xcode Extension.app") + workspace.activateFileViewerSelecting([url]) + } + }) { + Text("Reveal Extension Service in Finder") + } + + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + )! + NSWorkspace.shared.open(url) + }) { + Text("Accessibility Settings") + } + + Button(action: { + let url = URL( + string: "x-apple.systempreferences:com.apple.ExtensionsPreferences" + )! + NSWorkspace.shared.open(url) + }) { + Text("Extensions Settings") + } + } + } + } + .padding() + } +} + +struct LaunchAgentView: View { + @Environment(\.toast) var toast + @State var isDidRemoveLaunchAgentAlertPresented = false + @State var isDidSetupLaunchAgentAlertPresented = false + @State var isDidRestartLaunchAgentAlertPresented = false + + var body: some View { + VStack(alignment: .leading) { + HStack { + Button(action: { + Task { + do { + try await LaunchAgentManager().setupLaunchAgent() + isDidSetupLaunchAgentAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + }) { + Text("Set Up Launch Agent") + } + .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) { + .init( + title: Text("Finished Launch Agent Setup"), + message: Text( + "Please refresh the Copilot status. (The first refresh may fail)" + ), + dismissButton: .default(Text("OK")) + ) + } + + Button(action: { + Task { + do { + try await LaunchAgentManager().removeLaunchAgent() + isDidRemoveLaunchAgentAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + }) { + Text("Remove Launch Agent") + } + .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) { + .init( + title: Text("Launch Agent Removed"), + dismissButton: .default(Text("OK")) + ) + } + + Button(action: { + Task { + do { + try await LaunchAgentManager().reloadLaunchAgent() + isDidRestartLaunchAgentAlertPresented = true + } catch { + toast(error.localizedDescription, .error) + } + } + }) { + Text("Reload Launch Agent") + }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) { + .init( + title: Text("Launch Agent Reloaded"), + dismissButton: .default(Text("OK")) + ) + } + } + } + .padding() + } +} + +struct GeneralSettingsView: View { + class Settings: ObservableObject { + @AppStorage(\.quitXPCServiceOnXcodeAndAppQuit) + var quitXPCServiceOnXcodeAndAppQuit + @AppStorage(\.suggestionWidgetPositionMode) + var suggestionWidgetPositionMode + @AppStorage(\.widgetColorScheme) + var widgetColorScheme + @AppStorage(\.preferWidgetToStayInsideEditorWhenWidthGreaterThan) + var preferWidgetToStayInsideEditorWhenWidthGreaterThan + @AppStorage(\.showHideWidgetShortcutGlobally) + var showHideWidgetShortcutGlobally + @AppStorage(\.installPrereleases) + var installPrereleases + } + + @StateObject var settings = Settings() + @Environment(\.updateChecker) var updateChecker + @State var automaticallyCheckForUpdate: Bool? + + var body: some View { + Form { + Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) { + Text("Quit service when Xcode and host app are terminated") + } + + Toggle(isOn: .init( + get: { automaticallyCheckForUpdate ?? updateChecker.automaticallyChecksForUpdates }, + set: { + updateChecker.automaticallyChecksForUpdates = $0 + automaticallyCheckForUpdate = $0 + } + )) { + Text("Automatically Check for Updates") + } + + Toggle(isOn: $settings.installPrereleases) { + Text("Install pre-releases") + } + }.padding() + } +} + +struct GeneralView_Previews: PreviewProvider { + static var previews: some View { + GeneralView(store: .init(initialState: .init(), reducer: { General() })) + .frame(height: 800) + } +} + diff --git a/Core/Sources/HostApp/HandleToast.swift b/Core/Sources/HostApp/HandleToast.swift new file mode 100644 index 0000000..564fdad --- /dev/null +++ b/Core/Sources/HostApp/HandleToast.swift @@ -0,0 +1,49 @@ +import Dependencies +import SwiftUI +import Toast + +struct ToastHandler: View { + @ObservedObject var toastController: ToastController + let namespace: String? + + init(toastController: ToastController, namespace: String?) { + _toastController = .init(wrappedValue: toastController) + self.namespace = namespace + } + + var body: some View { + VStack(spacing: 4) { + ForEach(toastController.messages) { message in + 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)) + .shadow(color: Color.black.opacity(0.2), radius: 4) + } + } + } + .padding() + .allowsHitTesting(false) + } +} + +extension View { + func handleToast(namespace: String? = nil) -> some 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) + } + } +} + diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift new file mode 100644 index 0000000..fc03d87 --- /dev/null +++ b/Core/Sources/HostApp/HostApp.swift @@ -0,0 +1,70 @@ +import Client +import ComposableArchitecture +import Foundation +import KeyboardShortcuts + +extension KeyboardShortcuts.Name { + static let showHideWidget = Self("ShowHideWidget") +} + +@Reducer +struct HostApp { + @ObservableState + struct State: Equatable { + var general = General.State() + } + + enum Action: Equatable { + case appear + case general(General.Action) + } + + @Dependency(\.toast) var toast + + init() { + KeyboardShortcuts.userDefaults = .shared + } + + var body: some ReducerOf { + Scope(state: \.general, action: /Action.general) { + General() + } + + Reduce { _, action in + switch action { + case .appear: + return .none + + case .general: + return .none + } + } + } +} + +import Dependencies +import Preferences + +struct UserDefaultsDependencyKey: DependencyKey { + static var liveValue: UserDefaultsType = UserDefaults.shared + static var previewValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppPreview")! + it.removePersistentDomain(forName: "HostAppPreview") + return it + }() + + static var testValue: UserDefaultsType = { + let it = UserDefaults(suiteName: "HostAppTest")! + it.removePersistentDomain(forName: "HostAppTest") + return it + }() +} + +extension DependencyValues { + var userDefaults: UserDefaultsType { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } +} + + diff --git a/Core/Sources/HostApp/IsPreview.swift b/Core/Sources/HostApp/IsPreview.swift new file mode 100644 index 0000000..4409ad0 --- /dev/null +++ b/Core/Sources/HostApp/IsPreview.swift @@ -0,0 +1,3 @@ +import Foundation + +var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift new file mode 100644 index 0000000..ee031cb --- /dev/null +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -0,0 +1,20 @@ +import Foundation +import LaunchAgentManager + +extension LaunchAgentManager { + init() { + self.init( + serviceIdentifier: Bundle.main + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + + ".CommunicationBridge", + executablePath: Bundle.main.bundleURL + .appendingPathComponent("Contents") + .appendingPathComponent("Applications") + .appendingPathComponent("CommunicationBridge") + .path, + bundleIdentifier: Bundle.main + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + ) + } +} + diff --git a/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift new file mode 100644 index 0000000..9c20b7d --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/CodeHighlightThemePicker.swift @@ -0,0 +1,71 @@ +import Foundation +import Preferences +import SwiftUI + +public struct CodeHighlightThemePicker: View { + public enum Scenario { + case suggestion + case promptToCode + case chat + } + + let scenario: Scenario + + public init(scenario: Scenario) { + self.scenario = scenario + } + + public var body: some View { + switch scenario { + case .suggestion: + SuggestionThemePicker() + case .promptToCode: + PromptToCodeThemePicker() + case .chat: + ChatThemePicker() + } + } + + struct SuggestionThemePicker: View { + @AppStorage(\.syncSuggestionHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct PromptToCodeThemePicker: View { + @AppStorage(\.syncPromptToCodeHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct ChatThemePicker: View { + @AppStorage(\.syncChatCodeHighlightTheme) var sync: Bool + var body: some View { + SyncToggle(sync: $sync) + } + } + + struct SyncToggle: View { + @Binding var sync: Bool + + var body: some View { + VStack(alignment: .leading) { + Toggle(isOn: $sync) { + Text("Sync color scheme with Xcode") + } + + Text("To refresh the theme, you must activate the extension service app once.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} + +#Preview { + @State var sync = false + return CodeHighlightThemePicker.SyncToggle(sync: $sync) +} + diff --git a/Core/Sources/HostApp/SharedComponents/SubSection.swift b/Core/Sources/HostApp/SharedComponents/SubSection.swift new file mode 100644 index 0000000..b294e3e --- /dev/null +++ b/Core/Sources/HostApp/SharedComponents/SubSection.swift @@ -0,0 +1,132 @@ +import SwiftUI + +struct SubSection: View { + let title: Title + let description: Description + @ViewBuilder let content: () -> Content + + init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.description = description + self.content = content + } + + var body: some View { + VStack(alignment: .leading) { + if !(title is EmptyView && description is EmptyView) { + VStack(alignment: .leading, spacing: 8) { + title + .font(.system(size: 14).weight(.semibold)) + + description + .multilineTextAlignment(.leading) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if !(title is EmptyView && description is EmptyView) { + Divider().padding(.bottom, 4) + } + + content() + } + .padding() + .background { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.1)) + } + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.secondary.opacity(0.2)) + } + } +} + +extension SubSection where Description == Text { + init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) { + self.init(title: title, description: Text(description), content: content) + } +} + +extension SubSection where Description == EmptyView { + init(title: Title, @ViewBuilder content: @escaping () -> Content) { + self.init(title: title, description: EmptyView(), content: content) + } +} + +extension SubSection where Title == EmptyView { + init(description: Description, @ViewBuilder content: @escaping () -> Content) { + self.init(title: EmptyView(), description: description, content: content) + } +} + +extension SubSection where Title == EmptyView, Description == EmptyView { + init(@ViewBuilder content: @escaping () -> Content) { + self.init(title: EmptyView(), description: EmptyView(), content: content) + } +} + +extension SubSection where Title == EmptyView, Description == Text { + init(description: String, @ViewBuilder content: @escaping () -> Content) { + self.init(title: EmptyView(), description: description, content: content) + } +} + +#Preview("Sub Section Default Style") { + SubSection(title: Text("Title"), description: "Description") { + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Picker("Label", selection: .constant(0)) { + Text("Label").tag(0) + Text("Label").tag(1) + Text("Label").tag(2) + } + } + .padding() +} + +#Preview("Sub Section No Title") { + SubSection(description: "Description") { + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Picker("Label", selection: .constant(0)) { + Text("Label").tag(0) + Text("Label").tag(1) + Text("Label").tag(2) + } + } + .padding() +} + +#Preview("Sub Section No Title or Description") { + SubSection { + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Toggle(isOn: .constant(true), label: { + Text("Label") + }) + + Picker("Label", selection: .constant(0)) { + Text("Label").tag(0) + Text("Label").tag(1) + Text("Label").tag(2) + } + } + .padding() +} + diff --git a/Core/Sources/HostApp/SidebarTabView.swift b/Core/Sources/HostApp/SidebarTabView.swift new file mode 100644 index 0000000..ae5b009 --- /dev/null +++ b/Core/Sources/HostApp/SidebarTabView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +private struct SidebarItem: Identifiable, Equatable { + var id: Int { tag } + var tag: Int + var title: String + var subtitle: String? = nil + var image: String? = nil +} + +private struct SidebarItemPreferenceKey: PreferenceKey { + static var defaultValue: [SidebarItem] = [] + static func reduce(value: inout [SidebarItem], nextValue: () -> [SidebarItem]) { + value.append(contentsOf: nextValue()) + } +} + +private struct SidebarTabTagKey: EnvironmentKey { + static var defaultValue: Int = 0 +} + +private extension EnvironmentValues { + var sidebarTabTag: Int { + get { self[SidebarTabTagKey.self] } + set { self[SidebarTabTagKey.self] = newValue } + } +} + +private struct SidebarTabViewWrapper: View { + @Environment(\.sidebarTabTag) var sidebarTabTag + var tag: Int + var title: String + var subtitle: String? = nil + var image: String? = nil + var content: () -> Content + + var body: some View { + Group { + if tag == sidebarTabTag { + content() + } else { + Color.clear + } + } + .preference( + key: SidebarItemPreferenceKey.self, + value: [.init(tag: tag, title: title, subtitle: subtitle, image: image)] + ) + } +} + +extension View { + func sidebarItem( + tag: Int, + title: String, + subtitle: String? = nil, + image: String? = nil + ) -> some View { + SidebarTabViewWrapper( + tag: tag, + title: title, + subtitle: subtitle, + image: image, + content: { self } + ) + } +} + +struct SidebarTabView: View { + @State private var sidebarItems = [SidebarItem]() + @Binding var tag: Int + @ViewBuilder var views: () -> Content + var body: some View { + HStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading) { + ForEach(sidebarItems) { item in + Button(action: { + tag = item.tag + }) { + HStack { + if let image = item.image { + Image(systemName: image) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .foregroundStyle(.primary) + if let subtitle = item.subtitle { + Text(subtitle) + .lineSpacing(0) + .font(.caption) + .foregroundStyle(.secondary) + .opacity(0.5) + .multilineTextAlignment(.leading) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.primary.opacity(tag == item.tag ? 0.1 : 0), + in: RoundedRectangle(cornerRadius: 4) + ) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 200) + .padding(.vertical, 8) + } + .background(Color.primary.opacity(0.05)) + + Divider() + + ZStack(alignment: .topLeading) { + views() + } + } + .environment(\.sidebarTabTag, tag) + .onPreferenceChange(SidebarItemPreferenceKey.self) { items in + sidebarItems = items + } + } +} + +struct SidebarTabView_Previews: PreviewProvider { + static var previews: some View { + SidebarTabView(tag: .constant(0)) { + Color.red.sidebarItem( + tag: 0, + title: "Hello", + subtitle: "Meow\nMeow", + image: "person.circle.fill" + ) + Color.blue.sidebarItem( + tag: 1, + title: "World", + image: "person.circle.fill" + ) + Color.blue.sidebarItem( + tag: 3, + title: "Pikachu", + image: "person.circle.fill" + ) + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift new file mode 100644 index 0000000..5602e86 --- /dev/null +++ b/Core/Sources/HostApp/TabContainer.swift @@ -0,0 +1,222 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import LaunchAgentManager +import SwiftUI +import Toast +import UpdateChecker + +@MainActor +let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() }) + +public struct TabContainer: View { + let store: StoreOf + @ObservedObject var toastController: ToastController + @State private var tabBarItems = [TabBarItem]() + @State var tag: Int = 0 + + public init() { + toastController = ToastControllerDependencyKey.liveValue + store = hostAppStore + } + + init(store: StoreOf, toastController: ToastController) { + self.store = store + self.toastController = toastController + } + + public var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + TabBar(tag: $tag, tabBarItems: tabBarItems) + .padding(.bottom, 8) + + Divider() + + ZStack(alignment: .center) { + GeneralView(store: store.scope(state: \.general, action: \.general)) + .tabBarItem( + tag: 0, + title: "General", + image: "gearshape" + ) + FeatureSettingsView().tabBarItem( + tag: 2, + title: "Feature", + image: "star.square" + ) + } + .environment(\.tabBarTabTag, tag) + .frame(minHeight: 400) + } + .focusable(false) + .padding(.top, 8) + .background(.ultraThinMaterial.opacity(0.01)) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) + .handleToast() + .onPreferenceChange(TabBarItemPreferenceKey.self) { items in + tabBarItems = items + } + .onAppear { + store.send(.appear) + } + } + } +} + +struct TabBar: View { + @Binding var tag: Int + fileprivate var tabBarItems: [TabBarItem] + + var body: some View { + HStack { + ForEach(tabBarItems) { tab in + TabBarButton( + currentTag: $tag, + tag: tab.tag, + title: tab.title, + image: tab.image + ) + } + } + } +} + +struct TabBarButton: View { + @Binding var currentTag: Int + @State var isHovered = false + var tag: Int + var title: String + var image: String + + var body: some View { + Button(action: { + self.currentTag = tag + }) { + VStack(spacing: 2) { + Image(systemName: image) + .resizable() + .scaledToFit() + .frame(height: 18) + Text(title) + } + .font(.body) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .padding(.top, 4) + .background( + tag == currentTag + ? Color(nsColor: .textColor).opacity(0.1) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + .background( + isHovered + ? Color(nsColor: .textColor).opacity(0.05) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + } + .onHover(perform: { yes in + isHovered = yes + }) + .buttonStyle(.borderless) + } +} + +private struct TabBarTabViewWrapper: View { + @Environment(\.tabBarTabTag) var tabBarTabTag + var tag: Int + var title: String + var image: String + var content: () -> Content + + var body: some View { + Group { + if tag == tabBarTabTag { + content() + } else { + Color.clear + } + } + .preference( + key: TabBarItemPreferenceKey.self, + value: [.init(tag: tag, title: title, image: image)] + ) + } +} + +private extension View { + func tabBarItem( + tag: Int, + title: String, + image: String + ) -> some View { + TabBarTabViewWrapper( + tag: tag, + title: title, + image: image, + content: { self } + ) + } +} + +private struct TabBarItem: Identifiable, Equatable { + var id: Int { tag } + var tag: Int + var title: String + var image: String +} + +private struct TabBarItemPreferenceKey: PreferenceKey { + static var defaultValue: [TabBarItem] = [] + static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) { + value.append(contentsOf: nextValue()) + } +} + +private struct TabBarTabTagKey: EnvironmentKey { + static var defaultValue: Int = 0 +} + +private extension EnvironmentValues { + var tabBarTabTag: Int { + get { self[TabBarTabTagKey.self] } + set { self[TabBarTabTagKey.self] = newValue } + } +} + +struct UpdateCheckerKey: EnvironmentKey { + static var defaultValue: UpdateChecker = .init(hostBundle: nil) +} + +public extension EnvironmentValues { + var updateChecker: UpdateChecker { + get { self[UpdateCheckerKey.self] } + set { self[UpdateCheckerKey.self] = newValue } + } +} + +// MARK: - Previews + +struct TabContainer_Previews: PreviewProvider { + static var previews: some View { + TabContainer() + .frame(width: 800) + } +} + +struct TabContainer_Toasts_Previews: PreviewProvider { + static var previews: some View { + 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")), + ]) + ) + .frame(width: 800) + } +} + diff --git a/Core/Sources/KeyBindingManager/KeyBindingManager.swift b/Core/Sources/KeyBindingManager/KeyBindingManager.swift new file mode 100644 index 0000000..2fcf67f --- /dev/null +++ b/Core/Sources/KeyBindingManager/KeyBindingManager.swift @@ -0,0 +1,29 @@ +import Foundation +import Workspace +public final class KeyBindingManager { + let tabToAcceptSuggestion: TabToAcceptSuggestion + public init( + workspacePool: WorkspacePool, + acceptSuggestion: @escaping () -> Void, + expandSuggestion: @escaping () -> Void, + collapseSuggestion: @escaping () -> Void, + dismissSuggestion: @escaping () -> Void + ) { + tabToAcceptSuggestion = .init( + workspacePool: workspacePool, + acceptSuggestion: acceptSuggestion, + dismissSuggestion: dismissSuggestion, + expandSuggestion: expandSuggestion, + collapseSuggestion: collapseSuggestion + ) + } + + public func start() { + tabToAcceptSuggestion.start() + } + + @MainActor + public func stopForExit() { + tabToAcceptSuggestion.stopForExit() + } +} diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift new file mode 100644 index 0000000..dbbacf9 --- /dev/null +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -0,0 +1,199 @@ +import ActiveApplicationMonitor +import AppKit +import CGEventOverride +import Foundation +import Logger +import Preferences +import SuggestionBasic +import UserDefaultsObserver +import Workspace +import XcodeInspector + +final class TabToAcceptSuggestion { + let hook: CGEventHookType = CGEventHook(eventsOfInterest: [.keyDown]) { message in + Logger.service.debug("TabToAcceptSuggestion: \(message)") + } + + let workspacePool: WorkspacePool + let acceptSuggestion: () -> Void + let expandSuggestion: () -> Void + let collapseSuggestion: () -> Void + let dismissSuggestion: () -> Void + private var modifierEventMonitor: Any? + private let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().acceptSuggestionWithTab.key, + UserDefaultPreferenceKeys().dismissSuggestionWithEsc.key, + ], context: nil + ) + private var stoppedForExit = false + + struct ObservationKey: Hashable {} + + var canTapToAcceptSuggestion: Bool { + UserDefaults.shared.value(for: \.acceptSuggestionWithTab) + } + + var canEscToDismissSuggestion: Bool { + UserDefaults.shared.value(for: \.dismissSuggestionWithEsc) + } + + @MainActor + func stopForExit() { + stoppedForExit = true + stopObservation() + } + + init( + workspacePool: WorkspacePool, + acceptSuggestion: @escaping () -> Void, + dismissSuggestion: @escaping () -> Void, + expandSuggestion: @escaping () -> Void, + collapseSuggestion: @escaping () -> Void + ) { + _ = ThreadSafeAccessToXcodeInspector.shared + self.workspacePool = workspacePool + self.acceptSuggestion = acceptSuggestion + self.dismissSuggestion = dismissSuggestion + self.expandSuggestion = expandSuggestion + self.collapseSuggestion = collapseSuggestion + + hook.add( + .init( + eventsOfInterest: [.keyDown], + convert: { [weak self] _, _, event in + self?.handleEvent(event) ?? .unchanged + } + ), + forKey: ObservationKey() + ) + } + + func start() { + Task { [weak self] in + for await _ in ActiveApplicationMonitor.shared.createInfoStream() { + guard let self else { return } + try Task.checkCancellation() + Task { @MainActor in + if ActiveApplicationMonitor.shared.activeXcode != nil { + self.startObservation() + } else { + self.stopObservation() + } + } + } + } + + userDefaultsObserver.onChange = { [weak self] in + guard let self else { return } + Task { @MainActor in + if self.canTapToAcceptSuggestion { + self.startObservation() + } else { + self.stopObservation() + } + } + } + } + + @MainActor + func startObservation() { + guard !stoppedForExit else { return } + guard canTapToAcceptSuggestion else { return } + hook.activateIfPossible() + removeMonitor() + modifierEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in + self?.handleModifierEvents(event: event) + } + } + + @MainActor + func stopObservation() { + hook.deactivate() + removeMonitor() + } + + private func removeMonitor() { + if let monitor = modifierEventMonitor { + NSEvent.removeMonitor(monitor) + modifierEventMonitor = nil + } + } + + func handleEvent(_ event: CGEvent) -> CGEventManipulation.Result { + if Self.shouldAcceptSuggestion( + event: event, + workspacePool: workspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspector.shared + ) { + acceptSuggestion() + return .discarded + } + return .unchanged + } + + func handleModifierEvents(event: NSEvent) { + if event.modifierFlags.contains(NSEvent.ModifierFlags.option) { + expandSuggestion() + } else { + collapseSuggestion() + } + } +} + +extension TabToAcceptSuggestion { + /// Returns whether a given keyboard event should be intercepted and trigger + /// accepting a suggestion. + static func shouldAcceptSuggestion( + event: CGEvent, + workspacePool: WorkspacePool, + xcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol + ) -> Bool { + let keycode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let tab = 48 + guard keycode == tab else { return false } + guard let fileURL = xcodeInspector.activeDocumentURL else { return false } + if event.flags.contains(.maskHelp) { return false } + if event.flags.contains(.maskShift) { return false } + if event.flags.contains(.maskControl) { return false } + if event.flags.contains(.maskCommand) { return false } + guard xcodeInspector.hasActiveXcode else { return false } + guard xcodeInspector.hasFocusedEditor else { return false } + guard let filespace = workspacePool.fetchFilespaceIfExisted(fileURL: fileURL) else { return false } + if filespace.presentingSuggestion == nil { return false } + return true + } +} + +import Combine + +protocol ThreadSafeAccessToXcodeInspectorProtocol { + var activeDocumentURL: URL? {get} + var hasActiveXcode: Bool {get} + var hasFocusedEditor: Bool {get} +} + +private class ThreadSafeAccessToXcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol { + static let shared = ThreadSafeAccessToXcodeInspector() + + private(set) var activeDocumentURL: URL? + private(set) var hasActiveXcode = false + private(set) var hasFocusedEditor = false + private var cancellable: Set = [] + + init() { + let inspector = XcodeInspector.shared + + inspector.$activeDocumentURL.receive(on: DispatchQueue.main).sink { [weak self] newValue in + self?.activeDocumentURL = newValue + }.store(in: &cancellable) + + inspector.$activeXcode.receive(on: DispatchQueue.main).sink { [weak self] newValue in + self?.hasActiveXcode = newValue != nil + }.store(in: &cancellable) + + inspector.$focusedEditor.receive(on: DispatchQueue.main).sink { [weak self] newValue in + self?.hasFocusedEditor = newValue != nil + }.store(in: &cancellable) + } +} diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift new file mode 100644 index 0000000..17ce20f --- /dev/null +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -0,0 +1,171 @@ +import Foundation +import ServiceManagement + +public struct LaunchAgentManager { + let lastLaunchAgentVersionKey = "LastLaunchAgentVersion" + let serviceIdentifier: String + let executablePath: String + let bundleIdentifier: String + + var launchAgentDirURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents") + } + + var launchAgentPath: String { + launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path + } + + public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) { + self.serviceIdentifier = serviceIdentifier + self.executablePath = executablePath + self.bundleIdentifier = bundleIdentifier + } + + public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { + if #available(macOS 13, *) { + await removeObsoleteLaunchAgent() + try await setupLaunchAgent() + } else { + guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } + try await setupLaunchAgent() + await removeObsoleteLaunchAgent() + } + } + + public func setupLaunchAgent() async throws { + if #available(macOS 13, *) { + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try bridgeLaunchAgent.register() + } else { + let content = """ + + + + + Label + \(serviceIdentifier) + Program + \(executablePath) + MachServices + + \(serviceIdentifier) + + + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + + + + """ + if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { + try FileManager.default.createDirectory( + at: launchAgentDirURL, + withIntermediateDirectories: false + ) + } + FileManager.default.createFile( + atPath: launchAgentPath, + contents: content.data(using: .utf8) + ) + try await launchctl("load", launchAgentPath) + } + + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) + } + + public func removeLaunchAgent() async throws { + if #available(macOS 13, *) { + let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") + try await bridgeLaunchAgent.unregister() + } else { + try await launchctl("unload", launchAgentPath) + try FileManager.default.removeItem(atPath: launchAgentPath) + } + } + + public func reloadLaunchAgent() async throws { + if #unavailable(macOS 13) { + try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) + } + } + + public func removeObsoleteLaunchAgent() async { + if #available(macOS 13, *) { + let path = launchAgentPath + if FileManager.default.fileExists(atPath: path) { + try? await launchctl("unload", path) + try? FileManager.default.removeItem(atPath: path) + } + } else { + let path = launchAgentPath.replacingOccurrences( + of: "ExtensionService", + with: "XPCService" + ) + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } + } + } +} + +private func process(_ launchPath: String, _ args: [String]) async throws { + let task = Process() + task.launchPath = launchPath + task.arguments = args + task.environment = [ + "PATH": "/usr/bin", + ] + let outpipe = Pipe() + task.standardOutput = outpipe + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { process in + do { + if process.terminationStatus == 0 { + continuation.resume(returning: ()) + } else { + if let data = try? outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(throwing: E(errorDescription: content)) + } else { + continuation.resume( + throwing: E( + errorDescription: "Unknown error." + ) + ) + } + } + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + +private func helper(_ args: String...) async throws { + // TODO: A more robust way to locate the executable. + guard let url = Bundle.main.executableURL? + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Applications") + .appendingPathComponent("Helper") + else { throw E(errorDescription: "Unable to locate Helper.") } + return try await process(url.path, args) +} + +private func launchctl(_ args: String...) async throws { + return try await process("/bin/launchctl", args) +} + +struct E: Error, LocalizedError { + var errorDescription: String? +} + diff --git a/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift new file mode 100644 index 0000000..c6062ec --- /dev/null +++ b/Core/Sources/PromptToCodeService/PreviewPromptToCodeService.swift @@ -0,0 +1,48 @@ +import Foundation +import SuggestionBasic + +public final class PreviewPromptToCodeService: PromptToCodeServiceType { + public init() {} + + public func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + return AsyncThrowingStream { continuation in + Task { + let code = """ + struct Cat { + var name: String + } + + print("Hello world!") + """ + let description = "I have created a struct `Cat`." + var resultCode = "" + var resultDescription = "" + do { + for character in code { + try await Task.sleep(nanoseconds: 50_000_000) + resultCode.append(character) + continuation.yield((resultCode, resultDescription)) + } + for character in description { + try await Task.sleep(nanoseconds: 50_000_000) + resultDescription.append(character) + continuation.yield((resultCode, resultDescription)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + public func stopResponding() {} +} + diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift new file mode 100644 index 0000000..5967e31 --- /dev/null +++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift @@ -0,0 +1,114 @@ +import Dependencies +import Foundation +import SuggestionBasic + +public protocol PromptToCodeServiceType { + func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> + + func stopResponding() +} + +public struct PromptToCodeSource { + public var language: CodeLanguage + public var documentURL: URL + public var projectRootURL: URL + public var content: String + public var lines: [String] + public var range: CursorRange + + public init( + language: CodeLanguage, + documentURL: URL, + projectRootURL: URL, + content: String, + lines: [String], + range: CursorRange + ) { + self.language = language + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.content = content + self.lines = lines + self.range = range + } +} + +public struct PromptToCodeServiceDependencyKey: DependencyKey { + public static let liveValue: PromptToCodeServiceType = PreviewPromptToCodeService() + public static let previewValue: PromptToCodeServiceType = PreviewPromptToCodeService() +} + +public extension DependencyValues { + var promptToCodeService: PromptToCodeServiceType { + get { self[PromptToCodeServiceDependencyKey.self] } + set { self[PromptToCodeServiceDependencyKey.self] = newValue } + } + + var promptToCodeServiceFactory: () -> PromptToCodeServiceType { + get { self[PromptToCodeServiceFactoryDependencyKey.self] } + set { self[PromptToCodeServiceFactoryDependencyKey.self] = newValue } + } +} + +#if canImport(ContextAwarePromptToCodeService) + +import ContextAwarePromptToCodeService + +extension ContextAwarePromptToCodeService: PromptToCodeServiceType { + public func modifyCode( + code: String, + requirement: String, + source: PromptToCodeSource, + isDetached: Bool, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) async throws -> AsyncThrowingStream<(code: String, description: String), Error> { + try await modifyCode( + code: code, + requirement: requirement, + source: ContextAwarePromptToCodeService.Source( + language: source.language, + documentURL: source.documentURL, + projectRootURL: source.projectRootURL, + content: source.content, + lines: source.lines, + range: source.range + ), + isDetached: isDetached, + extraSystemPrompt: extraSystemPrompt, + generateDescriptionRequirement: generateDescriptionRequirement + ) + } +} + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { + ContextAwarePromptToCodeService() + } + + public static let previewValue: () -> PromptToCodeServiceType = { + PreviewPromptToCodeService() + } +} + +#else + +public struct PromptToCodeServiceFactoryDependencyKey: DependencyKey { + public static let liveValue: () -> PromptToCodeServiceType = { + PreviewPromptToCodeService() + } + + public static let previewValue: () -> PromptToCodeServiceType = { + PreviewPromptToCodeService() + } +} + +#endif + diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift new file mode 100644 index 0000000..6a1ace8 --- /dev/null +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -0,0 +1,27 @@ +import ConversationTab +import ChatService +import ChatTab +import Foundation +import PromptToCodeService +import SuggestionBasic +import SuggestionWidget +import XcodeInspector + +enum ChatTabFactory { + static func chatTabBuilderCollection() -> [ChatTabBuilderCollection] { + func folderIfNeeded( + _ builders: [any ChatTabBuilder], + title: String + ) -> ChatTabBuilderCollection? { + if builders.count > 1 { + return .folder(title: title, kinds: builders.map(ChatTabKind.init)) + } + if let first = builders.first { return .kind(ChatTabKind(first)) } + return nil + } + + return [ + folderIfNeeded(ConversationTab.chatBuilders(), title: ConversationTab.name), + ].compactMap { $0 } + } +} diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift new file mode 100644 index 0000000..f21e2da --- /dev/null +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift @@ -0,0 +1,405 @@ +import ActiveApplicationMonitor +import AppActivator +import AppKit +import ConversationTab +import ChatTab +import ComposableArchitecture +import Dependencies +import Preferences +import SuggestionBasic +import SuggestionWidget + +#if canImport(ChatTabPersistent) +import ChatTabPersistent +#endif + +@Reducer +struct GUI { + @ObservableState + struct State: Equatable { + var suggestionWidgetState = WidgetFeature.State() + + var chatTabGroup: ChatPanelFeature.ChatTabGroup { + get { suggestionWidgetState.chatPanelState.chatTabGroup } + set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue } + } + + var promptToCodeGroup: PromptToCodeGroup.State { + get { suggestionWidgetState.panelState.content.promptToCodeGroup } + set { suggestionWidgetState.panelState.content.promptToCodeGroup = newValue } + } + } + + enum Action { + case start + case openChatPanel(forceDetach: Bool) + case createAndSwitchToChatTabIfNeeded + case createAndSwitchToBrowserTabIfNeeded(url: URL) + case sendCustomCommandToActiveChat(CustomCommand) + case toggleWidgetsHotkeyPressed + + case suggestionWidget(WidgetFeature.Action) + + static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self { + .suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action)))) + } + + #if canImport(ChatTabPersistent) + case persistent(ChatTabPersistent.Action) + #endif + } + + @Dependency(\.chatTabPool) var chatTabPool + @Dependency(\.activateThisApp) var activateThisApp + + public enum Debounce: Hashable { + case updateChatTabOrder + } + + var body: some ReducerOf { + CombineReducers { + Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) { + WidgetFeature() + } + + Scope( + state: \.chatTabGroup, + action: \.suggestionWidget.chatPanel + ) { + Reduce { _, action in + switch action { + case let .createNewTapButtonClicked(kind): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: kind) { + await send(.appendAndSelectTab(chatTabInfo)) + } + } + + case let .closeTabButtonClicked(id): + return .run { _ in + chatTabPool.removeTab(of: id) + } + + case let .chatTab(_, .openNewTab(builder)): + return .run { send in + if let (_, chatTabInfo) = await chatTabPool + .createTab(from: builder.chatTabBuilder) + { + await send(.appendAndSelectTab(chatTabInfo)) + } + } + + default: + return .none + } + } + } + + #if canImport(ChatTabPersistent) + Scope(state: \.persistentState, action: \.persistent) { + ChatTabPersistent() + } + #endif + + Reduce { state, action in + switch action { + case .start: + #if canImport(ChatTabPersistent) + return .run { send in + await send(.persistent(.restoreChatTabs)) + } + #else + return .none + #endif + + case let .openChatPanel(forceDetach): + return .run { send in + await send( + .suggestionWidget( + .chatPanel(.presentChatPanel(forceDetach: forceDetach)) + ) + ) + await send(.suggestionWidget(.updateKeyWindow(.chatPanel))) + + activateThisApp() + } + + case .createAndSwitchToChatTabIfNeeded: + if let selectedTabInfo = state.chatTabGroup.selectedTabInfo, + chatTabPool.getTab(of: selectedTabInfo.id) is ConversationTab + { + // Already in Chat tab + return .none + } + + if let firstChatTabInfo = state.chatTabGroup.tabInfo.first(where: { + chatTabPool.getTab(of: $0.id) is ConversationTab + }) { + return .run { send in + await send(.suggestionWidget(.chatPanel(.tabClicked( + id: firstChatTabInfo.id + )))) + } + } + return .run { send in + if let (_, chatTabInfo) = await chatTabPool.createTab(for: nil) { + 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 + )))) + } + } + + 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 { + if tab.service.isReceivingMessage { + await tab.service.stopReceivingMessage() + } + try? await tab.service.handleCustomCommand(command) + } + + if let info = state.chatTabGroup.selectedTabInfo, + let activeTab = chatTabPool.getTab(of: info.id) as? ConversationTab + { + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(activeTab) + } + } + + if let info = state.chatTabGroup.tabInfo.first(where: { + chatTabPool.getTab(of: $0.id) is ConversationTab + }), + let chatTab = chatTabPool.getTab(of: info.id) as? ConversationTab + { + state.chatTabGroup.selectedTabId = chatTab.id + return .run { send in + await send(.openChatPanel(forceDetach: false)) + await stopAndHandleCommand(chatTab) + } + } + + return .run { send in + guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil) + else { + return + } + await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo)))) + await send(.openChatPanel(forceDetach: false)) + if let chatTab = chatTab as? ConversationTab { + await stopAndHandleCommand(chatTab) + } + } + + case .toggleWidgetsHotkeyPressed: + return .run { send in + await send(.suggestionWidget(.circularWidget(.widgetClicked))) + } + + case let .suggestionWidget(.chatPanel(.chatTab(id, .tabContentUpdated))): + #if canImport(ChatTabPersistent) + // when a tab is updated, persist it. + return .run { send in + await send(.persistent(.chatTabUpdated(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 + + #if canImport(ChatTabPersistent) + case .persistent: + return .none + #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 + } + } + } +} + +@MainActor +public final class GraphicalUserInterfaceController { + let store: StoreOf + let widgetController: SuggestionWidgetController + let widgetDataSource: WidgetDataSource + let chatTabPool: ChatTabPool + + class WeakStoreHolder { + weak var store: StoreOf? + } + + init() { + let chatTabPool = ChatTabPool() + let suggestionDependency = SuggestionWidgetControllerDependency() + let setupDependency: (inout DependencyValues) -> Void = { dependencies in + dependencies.suggestionWidgetControllerDependency = suggestionDependency + dependencies.suggestionWidgetUserDefaultsObservers = .init() + dependencies.chatTabPool = chatTabPool + dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection + dependencies.promptToCodeAcceptHandler = { promptToCode in + Task { + let handler = PseudoCommandHandler() + await handler.acceptPromptToCode() + if !promptToCode.isContinuous { + NSWorkspace.activatePreviousActiveXcode() + } else { + NSWorkspace.activateThisApp() + } + } + } + } + let store = StoreOf( + initialState: .init(), + reducer: { GUI() }, + withDependencies: setupDependency + ) + self.store = store + self.chatTabPool = chatTabPool + widgetDataSource = .init() + + widgetController = SuggestionWidgetController( + store: store.scope( + state: \.suggestionWidgetState, + action: \.suggestionWidget + ), + chatTabPool: chatTabPool, + dependency: suggestionDependency + ) + + chatTabPool.createStore = { id in + store.scope( + state: { state in + state.chatTabGroup.tabInfo[id: id] ?? .init(id: id, title: "") + }, + action: { childAction in + .suggestionWidget(.chatPanel(.chatTab(id: id, action: childAction))) + } + ) + } + + suggestionDependency.suggestionWidgetDataSource = widgetDataSource + suggestionDependency.onOpenChatClicked = { [weak self] in + Task { [weak self] in + await self?.store.send(.createAndSwitchToChatTabIfNeeded).finish() + self?.store.send(.openChatPanel(forceDetach: false)) + } + } + suggestionDependency.onCustomCommandClicked = { command in + Task { + let commandHandler = PseudoCommandHandler() + await commandHandler.handleCustomCommand(command) + } + } + } + + func start() { + store.send(.start) + } + + public func openGlobalChat() { + PseudoCommandHandler().openChat(forceDetach: true) + } +} + +extension ChatTabPool { + @MainActor + func createTab( + id: String = UUID().uuidString, + from builder: ChatTabBuilder + ) 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) + } + + @MainActor + func createTab( + for kind: ChatTabKind? + ) async -> (any ChatTab, ChatTabInfo)? { + let id = UUID().uuidString + let info = ChatTabInfo(id: id, title: "") + guard let builder = kind?.builder else { + let chatTap = ConversationTab(store: createStore(id)) + setTab(chatTap) + return (chatTap, info) + } + + guard let chatTap = await builder.build(store: createStore(id)) else { return nil } + setTab(chatTap) + return (chatTap, info) + } +} + diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift new file mode 100644 index 0000000..01611d1 --- /dev/null +++ b/Core/Sources/Service/GUI/WidgetDataSource.swift @@ -0,0 +1,67 @@ +import ActiveApplicationMonitor +import AppActivator +import AppKit +import ChatService +import ComposableArchitecture +import Foundation +import GitHubCopilotService +import ChatAPIService +import PromptToCodeService +import SuggestionBasic +import SuggestionWidget + +@MainActor +final class WidgetDataSource {} + +extension WidgetDataSource: SuggestionWidgetDataSource { + func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { + for workspace in Service.shared.workspacePool.workspaces.values { + if let filespace = workspace.filespaces[url], + let suggestion = filespace.presentingSuggestion + { + return .init( + code: suggestion.text, + language: filespace.language.rawValue, + startLineIndex: suggestion.position.line, + suggestionCount: filespace.suggestions.count, + currentSuggestionIndex: filespace.suggestionIndex, + onSelectPreviousSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.presentPreviousSuggestion() + } + }, + onSelectNextSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.presentNextSuggestion() + } + }, + onRejectSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.rejectSuggestions() + NSWorkspace.activatePreviousActiveXcode() + } + }, + onAcceptSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.acceptSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } + }, + onDismissSuggestionTapped: { + Task { + let handler = PseudoCommandHandler() + await handler.dismissSuggestion() + NSWorkspace.activatePreviousActiveXcode() + } + } + ) + } + } + return nil + } +} + diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift new file mode 100644 index 0000000..9620f25 --- /dev/null +++ b/Core/Sources/Service/GlobalShortcutManager.swift @@ -0,0 +1,63 @@ +import AppKit +import Combine +import Foundation +import KeyboardShortcuts +import XcodeInspector + +extension KeyboardShortcuts.Name { + static let showHideWidget = Self("ShowHideWidget") +} + +@MainActor +final class GlobalShortcutManager { + let guiController: GraphicalUserInterfaceController + private var cancellable = Set() + + nonisolated init(guiController: GraphicalUserInterfaceController) { + self.guiController = guiController + } + + func start() { + KeyboardShortcuts.userDefaults = .shared + setupShortcutIfNeeded() + + KeyboardShortcuts.onKeyUp(for: .showHideWidget) { [guiController] in + let isXCodeActive = XcodeInspector.shared.activeXcode != nil + + if !isXCodeActive, + !guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed, + UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) + { + guiController.store.send(.openChatPanel(forceDetach: true)) + } else { + guiController.store.send(.toggleWidgetsHotkeyPressed) + } + } + + XcodeInspector.shared.$activeApplication.sink { app in + if !UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally) { + let shouldBeEnabled = if let app, app.isXcode || app.isExtensionService { + true + } else { + false + } + if shouldBeEnabled { + self.setupShortcutIfNeeded() + } else { + self.removeShortcutIfNeeded() + } + } else { + self.setupShortcutIfNeeded() + } + }.store(in: &cancellable) + } + + func setupShortcutIfNeeded() { + KeyboardShortcuts.enable(.showHideWidget) + } + + func removeShortcutIfNeeded() { + KeyboardShortcuts.disable(.showHideWidget) + } +} + diff --git a/Core/Sources/Service/Helpers.swift b/Core/Sources/Service/Helpers.swift new file mode 100644 index 0000000..0dfede8 --- /dev/null +++ b/Core/Sources/Service/Helpers.swift @@ -0,0 +1,51 @@ +import Foundation +import LanguageServerProtocol + +extension NSError { + static func from(_ error: Error) -> NSError { + if let error = error as? ServerError { + var message = "Unknown" + switch error { + case let .handlerUnavailable(handler): + message = "Handler unavailable: \(handler)." + case let .unhandledMethod(method): + message = "Methond unhandled: \(method)." + case let .notificationDispatchFailed(error): + message = "Notification dispatch failed: \(error.localizedDescription)." + case let .requestDispatchFailed(error): + message = "Request dispatch failed: \(error.localizedDescription)." + case let .clientDataUnavailable(error): + message = "Client data unavailable: \(error.localizedDescription)." + case .serverUnavailable: + message = "Server unavailable, please make sure you have installed Node." + case .missingExpectedParameter: + message = "Missing expected parameter." + case .missingExpectedResult: + message = "Missing expected result." + case let .unableToDecodeRequest(error): + message = "Unable to decode request: \(error.localizedDescription)." + case let .unableToSendRequest(error): + message = "Unable to send request: \(error.localizedDescription)." + case let .unableToSendNotification(error): + message = "Unable to send notification: \(error.localizedDescription)." + case let .serverError(code, m, _): + message = "Server error: (\(code)) \(m)." + case let .invalidRequest(error): + message = "Invalid request: \(error?.localizedDescription ?? "Unknown")." + case .timeout: + message = "Timeout." + } + return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + if let error = error as? CancellationError { + return NSError(domain: "com.github.CopilotForXcode", code: -100, userInfo: [ + NSLocalizedDescriptionKey: error.localizedDescription, + ]) + } + return NSError(domain: "com.github.CopilotForXcode", code: -1, userInfo: [ + NSLocalizedDescriptionKey: error.localizedDescription, + ]) + } +} diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift new file mode 100644 index 0000000..71225aa --- /dev/null +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -0,0 +1,193 @@ +import ActiveApplicationMonitor +import AppKit +import AsyncAlgorithms +import AXExtension +import Combine +import Foundation +import Logger +import Preferences +import QuartzCore +import Workspace +import XcodeInspector + +public actor RealtimeSuggestionController { + private var cancellable: Set = [] + private var inflightPrefetchTask: Task? + private var editorObservationTask: Task? + private var sourceEditor: SourceEditor? + + init() {} + + deinit { + cancellable.forEach { $0.cancel() } + inflightPrefetchTask?.cancel() + editorObservationTask?.cancel() + } + + nonisolated + func start() { + Task { await observeXcodeChange() } + } + + private func observeXcodeChange() { + cancellable.forEach { $0.cancel() } + + XcodeInspector.shared.$focusedEditor + .sink { [weak self] editor in + guard let self else { return } + Task { + guard let editor else { return } + await self.handleFocusElementChange(editor) + } + }.store(in: &cancellable) + } + + private func handleFocusElementChange(_ sourceEditor: SourceEditor) { + self.sourceEditor = sourceEditor + + let notificationsFromEditor = sourceEditor.axNotifications + + editorObservationTask?.cancel() + editorObservationTask = nil + + editorObservationTask = Task { [weak self] in + if let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL { + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } + + let valueChange = await notificationsFromEditor.notifications() + .filter { $0.kind == .valueChanged } + let selectedTextChanged = await notificationsFromEditor.notifications() + .filter { $0.kind == .selectedTextChanged } + + await withTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in + let handler = { [weak self] in + guard let self else { return } + await cancelInFlightTasks() + await self.triggerPrefetchDebounced() + await self.notifyEditingFileChange(editor: sourceEditor.element) + } + + if #available(macOS 13.0, *) { + for await _ in valueChange._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in valueChange { + if Task.isCancelled { return } + await handler() + } + } + } + group.addTask { + let handler = { + guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL + else { return } + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } + + if #available(macOS 13.0, *) { + for await _ in selectedTextChanged._throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in selectedTextChanged { + if Task.isCancelled { return } + await handler() + } + } + } + + await group.waitForAll() + } + } + + Task { @WorkspaceActor in // Get cache ready for real-time suggestions. + guard UserDefaults.shared.value(for: \.preCacheOnFileOpen) else { return } + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + if filespace.codeMetadata.uti == nil { + // avoid the command get called twice + filespace.codeMetadata.uti = "" + do { + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Sync Text Settings") + } catch { + if filespace.codeMetadata.uti?.isEmpty ?? true { + filespace.codeMetadata.uti = nil + } + } + } + } + } + + func triggerPrefetchDebounced(force: Bool = false) { + inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in + try? await Task.sleep(nanoseconds: UInt64( + max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15) + * 1_000_000_000 + )) + + if Task.isCancelled { return } + + guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + else { return } + + if UserDefaults.shared.value(for: \.disableSuggestionFeatureGlobally), + let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + { + let isEnabled = workspace.isSuggestionFeatureEnabled + if !isEnabled { return } + } + if Task.isCancelled { return } + + // So the editor won't be blocked (after information are cached)! + await PseudoCommandHandler().generateRealtimeSuggestions(sourceEditor: sourceEditor) + } + } + + func cancelInFlightTasks(excluding: Task? = nil) async { + inflightPrefetchTask?.cancel() + + // cancel in-flight tasks + await withTaskGroup(of: Void.self) { group in + for (_, workspace) in Service.shared.workspacePool.workspaces { + group.addTask { + await workspace.cancelInFlightRealtimeSuggestionRequests() + } + } + } + } + + /// This method will still return true if the completion panel is hidden by esc. + /// Looks like the Xcode will keep the panel around until content is changed, + /// not sure how to observe that it's hidden. + func isCompletionPanelPresenting() -> Bool { + guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return false } + let application = AXUIElementCreateApplication(activeXcode.processIdentifier) + return application.focusedWindow?.child(identifier: "_XC_COMPLETION_TABLE_") != nil + } + + func notifyEditingFileChange(editor: AXUIElement) async { + guard let fileURL = await XcodeInspector.shared.safe.activeDocumentURL, + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return } + await workspace.didUpdateFilespace(fileURL: fileURL, content: editor.value) + } +} + diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift new file mode 100644 index 0000000..2178ba5 --- /dev/null +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -0,0 +1,92 @@ +import ActiveApplicationMonitor +import AppKit +import AXExtension +import BuiltinExtension +import Foundation +import Logger +import Workspace +import XcodeInspector + +public final class ScheduledCleaner { + weak var service: Service? + + init() {} + + func start() { + Task { @ServiceActor in + while !Task.isCancelled { + try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) + await cleanUp() + } + } + + Task { @ServiceActor in + for await app in ActiveApplicationMonitor.shared.createInfoStream() { + try Task.checkCancellation() + if let app, !app.isXcode { + await cleanUp() + } + } + } + } + + @ServiceActor + func cleanUp() async { + guard let service else { return } + + let workspaceInfos = XcodeInspector.shared.xcodes.reduce( + into: [ + XcodeAppInstanceInspector.WorkspaceIdentifier: + XcodeAppInstanceInspector.WorkspaceInfo + ]() + ) { result, xcode in + let infos = xcode.realtimeWorkspaces + for (id, info) in infos { + if let existed = result[id] { + result[id] = existed.combined(with: info) + } else { + result[id] = info + } + } + } + for (url, workspace) in service.workspacePool.workspaces { + if workspace.isExpired, workspaceInfos[.url(url)] == nil { + Logger.service.info("Remove idle workspace") + _ = await Task { @MainActor in + service.guiController.store.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: Array( + workspace.filespaces.keys + ))) + ) + }.result + await workspace.cleanUp(availableTabs: []) + await service.workspacePool.removeWorkspace(url: url) + } else { + let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) + .union(workspaceInfos[.unknown]?.tabs ?? []) + // cleanup chats for unused files + let filespaces = workspace.filespaces + for (url, _) in filespaces { + if workspace.isFilespaceExpired( + fileURL: url, + availableTabs: tabs + ) { + _ = await Task { @MainActor in + service.guiController.store.send( + .promptToCodeGroup(.discardExpiredPromptToCode(documentURLs: [url])) + ) + }.result + } + } + // cleanup workspace + await workspace.cleanUp(availableTabs: tabs) + } + } + } + + @ServiceActor + public func closeAllChildProcesses() async { + BuiltinExtensionManager.shared.terminate() + } +} + diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift new file mode 100644 index 0000000..3fb9afa --- /dev/null +++ b/Core/Sources/Service/Service.swift @@ -0,0 +1,122 @@ +import BuiltinExtension +import Combine +import Dependencies +import Foundation +import GitHubCopilotService +import KeyBindingManager +import Logger +import SuggestionService +import Toast +import Workspace +import WorkspaceSuggestionService +import XcodeInspector +import XcodeThemeController +import XPCShared +import SuggestionWidget + +@globalActor public enum ServiceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +/// The running extension service. +public final class Service { + public static let shared = Service() + + @WorkspaceActor + let workspacePool: WorkspacePool + @MainActor + public let guiController = GraphicalUserInterfaceController() + public let realtimeSuggestionController = RealtimeSuggestionController() + public let scheduledCleaner: ScheduledCleaner + let globalShortcutManager: GlobalShortcutManager + let keyBindingManager: KeyBindingManager + let xcodeThemeController: XcodeThemeController = .init() + + @Dependency(\.toast) var toast + var cancellable = Set() + + private init() { + @Dependency(\.workspacePool) var workspacePool + + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool) + ]) + scheduledCleaner = .init() + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + self.workspacePool = workspacePool + + globalShortcutManager = .init(guiController: guiController) + keyBindingManager = .init( + workspacePool: workspacePool, + acceptSuggestion: { + Task { await PseudoCommandHandler().acceptSuggestion() } + }, + expandSuggestion: { + if !ExpandableSuggestionService.shared.isSuggestionExpanded { + ExpandableSuggestionService.shared.isSuggestionExpanded = true + } + }, + collapseSuggestion: { + if ExpandableSuggestionService.shared.isSuggestionExpanded { + ExpandableSuggestionService.shared.isSuggestionExpanded = false + } + }, + dismissSuggestion: { + Task { await PseudoCommandHandler().dismissSuggestion() } + } + ) + let scheduledCleaner = ScheduledCleaner() + + scheduledCleaner.service = self + } + + @MainActor + public func start() { + scheduledCleaner.start() + realtimeSuggestionController.start() + guiController.start() + xcodeThemeController.start() + globalShortcutManager.start() + 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) + } + }.store(in: &cancellable) + } + } + + @MainActor + public func prepareForExit() async { + Logger.service.info("Prepare for exit.") + keyBindingManager.stopForExit() + await scheduledCleaner.closeAllChildProcesses() + } +} + +public extension Service { + func handleXPCServiceRequests( + endpoint: String, + requestBody: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + reply(nil, XPCRequestNotHandledError()) + } +} + diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift new file mode 100644 index 0000000..ac0b46e --- /dev/null +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -0,0 +1,444 @@ +import ActiveApplicationMonitor +import AppKit +import Dependencies +import Preferences +import SuggestionInjector +import SuggestionBasic +import Toast +import Workspace +import WorkspaceSuggestionService +import XcodeInspector +import XPCShared + +/// 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) + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + func presentPreviousSuggestion() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.presentPreviousSuggestion(editor: .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) + } + + func presentNextSuggestion() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.presentNextSuggestion(editor: .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) + } + + @WorkspaceActor + func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { + guard let filespace = await getFilespace(), + let (workspace, _) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } + + if Task.isCancelled { 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) } + + if filespace.presentingSuggestion != nil { + // Check if the current suggestion is still valid. + if filespace.validateSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return + } else { + presenter.discardSuggestion(fileURL: filespace.fileURL) + } + } + + do { + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + if let sourceEditor { + let editorContent = sourceEditor.getContent() + _ = filespace.validateSuggestions( + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition + ) + } + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } catch { + return + } + } + + @WorkspaceActor + func invalidateRealtimeSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async { + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + + if filespace.presentingSuggestion == nil { + return // skip if there's no suggestion presented. + } + + let content = sourceEditor.getContent() + if !filespace.validateSuggestions( + lines: content.lines, + cursorPosition: content.cursorPosition + ) { + PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) + } + } + + func rejectSuggestions() async { + let handler = WindowBaseCommandHandler() + _ = try? await handler.rejectSuggestion(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 { + if let it = await getEditorContent(sourceEditor: nil) { + return it + } + switch command.feature { + // editor content is not required. + case .customChat, .chatWithSelection, .singleRoundDialog: + return .init( + content: "", + lines: [], + uti: "", + cursorPosition: .outOfScope, + cursorOffset: -1, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + ) + // editor content is required. + case .promptToCode: + return nil + } + }() else { + do { + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: command.name) + } catch { + let presenter = PresentInWindowSuggestionPresenter() + presenter.presentError(error) + } + return + } + + let handler = WindowBaseCommandHandler() + do { + try await handler.handleCustomCommand(id: command.id, editor: editor) + } catch { + let presenter = PresentInWindowSuggestionPresenter() + presenter.presentError(error) + } + } + + func acceptPromptToCode() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + do { + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Prompt to Code") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + let now = Date() + if now.timeIntervalSince(last) > 60 * 60 { + Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now + toast.toast(content: """ + 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) + } + + throw error + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptPromptToCode(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + cursorOffset: cursorOffset, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + + func acceptSuggestion() async { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + do { + try await XcodeInspector.shared.safe.latestActiveXcode? + .triggerCopilotCommand(name: "Accept Suggestion") + } catch { + let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI + 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) + } + + throw error + } + } catch { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = application.focusedElement, + focusElement.description == "Source Editor" + else { return } + guard let ( + content, + lines, + _, + cursorPosition, + cursorOffset + ) = await getFileContent(sourceEditor: nil) + else { + PresentInWindowSuggestionPresenter() + .presentErrorMessage("Unable to get file content.") + return + } + let handler = WindowBaseCommandHandler() + do { + guard let result = try await handler.acceptSuggestion(editor: .init( + content: content, + lines: lines, + uti: "", + cursorPosition: cursorPosition, + cursorOffset: cursorOffset, + selections: [], + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false + )) else { return } + + try injectUpdatedCodeWithAccessibilityAPI(result, focusElement: focusElement) + } catch { + PresentInWindowSuggestionPresenter().presentError(error) + } + } + } + + func dismissSuggestion() async { + guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return } + guard let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return } + + await filespace.reset() + PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL) + } + + func openChat(forceDetach: Bool) { + let store = Service.shared.guiController.store + Task { @MainActor in + await store.send(.createAndSwitchToChatTabIfNeeded).finish() + store.send(.openChatPanel(forceDetach: forceDetach)) + } + } +} + +extension PseudoCommandHandler { + /// When Xcode commands are not available, we can fallback to directly + /// set the value of the editor with Accessibility API. + func injectUpdatedCodeWithAccessibilityAPI( + _ 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 + ) + } + } + + // 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 + )? + { + guard let xcode = ActiveApplicationMonitor.shared.activeXcode + ?? ActiveApplicationMonitor.shared.latestXcode else { return nil } + let application = AXUIElementCreateApplication(xcode.processIdentifier) + guard let focusElement = sourceEditor ?? application.focusedElement, + focusElement.description == "Source Editor" + else { return nil } + guard let selectionRange = focusElement.selectedTextRange else { return nil } + let content = focusElement.value + let split = content.breakLines(appendLineBreakToLastLine: false) + let range = SourceEditor.convertRangeToCursorRange(selectionRange, in: content) + return (content, split, [range], range.start, selectionRange.lowerBound) + } + + func getFileURL() async -> URL? { + await XcodeInspector.shared.safe.realtimeActiveDocumentURL + } + + @WorkspaceActor + func getFilespace() async -> Filespace? { + guard + let fileURL = await getFileURL(), + let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return nil } + return filespace + } + + @WorkspaceActor + func getEditorContent(sourceEditor: SourceEditor?) async -> EditorContent? { + guard let filespace = await getFilespace(), + let sourceEditor = await { + if let sourceEditor { sourceEditor } + else { await XcodeInspector.shared.safe.focusedEditor } + }() + else { return nil } + if Task.isCancelled { return nil } + let content = sourceEditor.getContent() + let uti = filespace.codeMetadata.uti ?? "" + let tabSize = filespace.codeMetadata.tabSize ?? 4 + let indentSize = filespace.codeMetadata.indentSize ?? 4 + let usesTabsForIndentation = filespace.codeMetadata.usesTabsForIndentation ?? false + return .init( + content: content.content, + lines: content.lines, + uti: uti, + cursorPosition: content.cursorPosition, + cursorOffset: content.cursorOffset, + selections: content.selections.map { + .init(start: $0.start, end: $0.end) + }, + tabSize: tabSize, + indentSize: indentSize, + usesTabsForIndentation: usesTabsForIndentation + ) + } +} + diff --git a/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift new file mode 100644 index 0000000..3d612e8 --- /dev/null +++ b/Core/Sources/Service/SuggestionCommandHandler/SuggestionCommandHandler.swift @@ -0,0 +1,25 @@ +import SuggestionBasic +import XPCShared + +protocol SuggestionCommandHandler { + @ServiceActor + func presentSuggestions(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func presentNextSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func presentPreviousSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func promptToCode(editor: EditorContent) async throws -> UpdatedContent? + @ServiceActor + func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? +} diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift new file mode 100644 index 0000000..d97618e --- /dev/null +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -0,0 +1,473 @@ +import AppKit +import ChatService +import Foundation +import GitHubCopilotService +import LanguageServerProtocol +import Logger +import ChatAPIService +import SuggestionInjector +import SuggestionBasic +import SuggestionWidget +import UserNotifications +import Workspace +import WorkspaceSuggestionService +import XcodeInspector +import XPCShared +import ChatService + +struct WindowBaseCommandHandler: SuggestionCommandHandler { + nonisolated init() {} + + let presenter = PresentInWindowSuggestionPresenter() + + func presentSuggestions(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _presentSuggestions(editor: editor) + } catch let error as ServerError { + Logger.service.error(error) + } catch { + presenter.presentError(error) + Logger.service.error(error) + } + } + return nil + } + + @WorkspaceActor + private func _presentSuggestions(editor: EditorContent) async throws { + presenter.markAsProcessing(true) + defer { + presenter.markAsProcessing(false) + } + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + + try Task.checkCancellation() + + try await workspace.generateSuggestions( + forFileAt: fileURL, + editor: editor + ) + + try Task.checkCancellation() + + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + func presentNextSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _presentNextSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _presentNextSuggestion(editor: EditorContent) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + workspace.selectNextSuggestion(forFileAt: fileURL) + + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + func presentPreviousSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _presentPreviousSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _presentPreviousSuggestion(editor: EditorContent) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + workspace.selectPreviousSuggestion(forFileAt: fileURL) + + if filespace.presentingSuggestion != nil { + presenter.presentSuggestion(fileURL: fileURL) + workspace.notifySuggestionShown(fileFileAt: fileURL) + } else { + presenter.discardSuggestion(fileURL: fileURL) + } + } + + func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await _rejectSuggestion(editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } + + @WorkspaceActor + private func _rejectSuggestion(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.rejectSuggestion(forFileAt: fileURL, editor: editor) + presenter.discardSuggestion(fileURL: fileURL) + } + + @WorkspaceActor + func acceptSuggestion(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.acceptSuggestion( + forFileAt: fileURL, + editor: editor, + suggestionLineLimit: ExpandableSuggestionService.shared.isSuggestionExpanded ? nil : 1 + ) { + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: acceptedSuggestion, + extraInfo: &extraInfo, + suggestionLineLimit: ExpandableSuggestionService.shared.isSuggestionExpanded ? nil : 1 + ) + + presenter.discardSuggestion(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 + else { return nil } + + let injector = SuggestionInjector() + var lines = editor.lines + var cursorPosition = editor.cursorPosition + var extraInfo = SuggestionInjector.ExtraInfo() + + let store = Service.shared.guiController.store + + if let promptToCode = store.state.promptToCodeGroup.activePromptToCode { + if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL { + return nil + } + + let range = { + if promptToCode.isAttachedToSelectionRange, + let range = promptToCode.selectionRange + { + return range + } + return editor.selections.first.map { + CursorRange(start: $0.start, end: $0.end) + } ?? CursorRange( + start: editor.cursorPosition, + end: editor.cursorPosition + ) + }() + + let suggestion = CodeSuggestion( + id: UUID().uuidString, + text: promptToCode.code, + position: range.start, + range: range + ) + + injector.acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursorPosition, + completion: suggestion, + extraInfo: &extraInfo + ) + + _ = await Task { @MainActor [cursorPosition] in + store.send( + .promptToCodeGroup(.updatePromptToCodeRange( + id: promptToCode.id, + range: .init(start: range.start, end: cursorPosition) + )) + ) + store.send( + .promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous( + id: promptToCode.id + )) + ) + }.result + + return .init( + content: String(lines.joined(separator: "")), + newSelection: .init(start: range.start, end: cursorPosition), + modifications: extraInfo.modifications + ) + } + + return nil + } + + func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? { + Task { + try? await prepareCache(editor: editor) + } + return nil + } + + @WorkspaceActor + func prepareCache(editor: EditorContent) async throws -> UpdatedContent? { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return nil } + let (_, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + filespace.codeMetadata.uti = editor.uti + filespace.codeMetadata.tabSize = editor.tabSize + filespace.codeMetadata.indentSize = editor.indentSize + filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation + filespace.codeMetadata.guessLineEnding(from: editor.lines.first) + return nil + } + + func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? { + return try await presentSuggestions(editor: editor) + } + + func promptToCode(editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await presentPromptToCode( + editor: editor, + extraSystemPrompt: nil, + prompt: nil, + isContinuous: false, + generateDescription: nil, + name: nil + ) + } catch { + presenter.presentError(error) + } + } + return nil + } + + func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? { + Task { + do { + try await handleCustomCommand(id: id, editor: editor) + } catch { + presenter.presentError(error) + } + } + return nil + } +} + +extension WindowBaseCommandHandler { + func handleCustomCommand(id: String, editor: EditorContent) async throws { + struct CommandNotFoundError: Error, LocalizedError { + var errorDescription: String? { "Command not found" } + } + + let availableCommands = UserDefaults.shared.value(for: \.customCommands) + guard let command = availableCommands.first(where: { $0.id == id }) + else { throw CommandNotFoundError() } + + switch command.feature { + case .chatWithSelection, .customChat: + Task { @MainActor in + Service.shared.guiController.store + .send(.sendCustomCommandToActiveChat(command)) + } + case let .promptToCode(extraSystemPrompt, prompt, continuousMode, generateDescription): + try await presentPromptToCode( + editor: editor, + extraSystemPrompt: extraSystemPrompt, + prompt: prompt, + isContinuous: continuousMode ?? false, + generateDescription: generateDescription, + name: command.name + ) + case let .singleRoundDialog( + systemPrompt, + overwriteSystemPrompt, + prompt, + receiveReplyInNotification + ): + try await executeSingleRoundDialog( + systemPrompt: systemPrompt, + overwriteSystemPrompt: overwriteSystemPrompt ?? false, + prompt: prompt ?? "", + receiveReplyInNotification: receiveReplyInNotification ?? false + ) + } + } + + @WorkspaceActor + func presentPromptToCode( + editor: EditorContent, + extraSystemPrompt: String?, + prompt: String?, + isContinuous: Bool, + generateDescription: Bool?, + name: String? + ) async throws { + guard let fileURL = await XcodeInspector.shared.safe.realtimeActiveDocumentURL + else { return } + let (workspace, filespace) = try await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + guard workspace.suggestionPlugin?.isSuggestionFeatureEnabled ?? false else { + presenter.presentErrorMessage("Prompt to code is disabled for this project") + return + } + + let codeLanguage = languageIdentifierFromFileURL(fileURL) + + let (code, selection) = { + guard var selection = editor.selections.last, + selection.start != selection.end + else { return ("", .cursor(editor.cursorPosition)) } + + let isMultipleLine = selection.start.line != selection.end.line + let isSpaceOnlyBeforeStartPositionOnTheSameLine = { + guard selection.start.line >= 0, selection.start.line < editor.lines.count else { + return false + } + let line = editor.lines[selection.start.line] + guard selection.start.character > 0, + selection.start.character < line.utf16.count + else { return false } + let substring = line[line.utf16.startIndex..<(line.index( + line.utf16.startIndex, + offsetBy: selection.start.character, + limitedBy: line.utf16.endIndex + ) ?? line.utf16.endIndex)] + return substring.allSatisfy { $0.isWhitespace } + }() + + if isMultipleLine || isSpaceOnlyBeforeStartPositionOnTheSameLine { + // when there are multiple lines start from char 0 so that it can keep the + // indentation. + selection.start = .init(line: selection.start.line, character: 0) + } + return ( + editor.selectedCode(in: selection), + .init( + start: .init(line: selection.start.line, character: selection.start.character), + end: .init(line: selection.end.line, character: selection.end.character) + ) + ) + }() as (String, CursorRange) + + let store = Service.shared.guiController.store + + let customCommandTemplateProcessor = CustomCommandTemplateProcessor() + + let newExtraSystemPrompt: String? = if let extraSystemPrompt { + await customCommandTemplateProcessor.process(extraSystemPrompt) + } else { + nil + } + + let newPrompt: String? = if let prompt { + await customCommandTemplateProcessor.process(prompt) + } else { + nil + } + + _ = await Task { @MainActor in + // if there is already a prompt to code presenting, we should not present another one + store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( + code: code, + selectionRange: selection, + language: codeLanguage, + identSize: filespace.codeMetadata.indentSize ?? 4, + usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false, + documentURL: fileURL, + projectRootURL: workspace.projectRootURL, + allCode: editor.content, + allLines: editor.lines, + isContinuous: isContinuous, + commandName: name, + defaultPrompt: newPrompt ?? "", + extraSystemPrompt: newExtraSystemPrompt, + generateDescriptionRequirement: generateDescription + )))) + }.result + } + + 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.") + } + } +} + diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift new file mode 100644 index 0000000..29780d4 --- /dev/null +++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift @@ -0,0 +1,66 @@ +import ChatService +import Foundation +import ChatAPIService +import SuggestionBasic +import SuggestionWidget + +struct PresentInWindowSuggestionPresenter { + func presentSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.suggestCode() + } + } + + func expandSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.expandSuggestion() + } + } + + func discardSuggestion(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.discardSuggestion() + } + } + + func markAsProcessing(_ isProcessing: Bool) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.markAsProcessing(isProcessing) + } + } + + func presentError(_ error: Error) { + if error is CancellationError { return } + if let urlError = error as? URLError, urlError.code == URLError.cancelled { return } + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.presentError(error.localizedDescription) + } + } + + func presentErrorMessage(_ message: String) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.presentError(message) + } + } + + func closeChatRoom(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.closeChatRoom() + } + } + + func presentChatRoom(fileURL: URL) { + Task { @MainActor in + let controller = Service.shared.guiController.widgetController + controller.presentChatRoom() + } + } +} + diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift new file mode 100644 index 0000000..d154aad --- /dev/null +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -0,0 +1,32 @@ +import Foundation +import SuggestionProvider +import Workspace +import WorkspaceSuggestionService + +extension Workspace { + @WorkspaceActor + func cleanUp(availableTabs: Set) { + for (fileURL, _) in filespaces { + if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { + openedFileRecoverableStorage.closeFile(fileURL: fileURL) + closeFilespace(fileURL: fileURL) + } + } + } + + func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { + let filename = fileURL.lastPathComponent + if availableTabs.contains(filename) { return false } + guard let filespace = filespaces[fileURL] else { return true } + return filespace.isExpired + } + + func cancelInFlightRealtimeSuggestionRequests() async { + guard let suggestionService else { return } + await suggestionService.cancelRequest(workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + )) + } +} + diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift new file mode 100644 index 0000000..1cffcc7 --- /dev/null +++ b/Core/Sources/Service/XPCService.swift @@ -0,0 +1,221 @@ +import AppKit +import Foundation +import GitHubCopilotService +import LanguageServerProtocol +import Logger +import Preferences +import XPCShared + +public class XPCService: NSObject, XPCServiceProtocol { + // MARK: - Service + + public func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) { + reply( + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "N/A", + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "N/A" + ) + } + + public func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) { + reply(AXIsProcessTrusted()) + } + + // MARK: - Suggestion + + @discardableResult + private func replyWithUpdatedContent( + editorContent: Data, + file: StaticString = #file, + line: UInt = #line, + isRealtimeSuggestionRelatedCommand: Bool = false, + withReply reply: @escaping (Data?, Error?) -> Void, + getUpdatedContent: @escaping @ServiceActor ( + SuggestionCommandHandler, + EditorContent + ) async throws -> UpdatedContent? + ) -> Task { + let task = Task { + do { + let editor = try JSONDecoder().decode(EditorContent.self, from: editorContent) + let handler: SuggestionCommandHandler = WindowBaseCommandHandler() + try Task.checkCancellation() + guard let updatedContent = try await getUpdatedContent(handler, editor) else { + reply(nil, nil) + return + } + try Task.checkCancellation() + try reply(JSONEncoder().encode(updatedContent), nil) + } catch { + Logger.service.error("\(file):\(line) \(error.localizedDescription)") + reply(nil, NSError.from(error)) + } + } + + Task { + await Service.shared.realtimeSuggestionController.cancelInFlightTasks(excluding: task) + } + return task + } + + public func getSuggestedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.presentSuggestions(editor: editor) + } + } + + public func getNextSuggestedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.presentNextSuggestion(editor: editor) + } + } + + public func getPreviousSuggestedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.presentPreviousSuggestion(editor: editor) + } + } + + public func getSuggestionRejectedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.rejectSuggestion(editor: editor) + } + } + + public func getSuggestionAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptSuggestion(editor: editor) + } + } + + public func getPromptToCodeAcceptedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.acceptPromptToCode(editor: editor) + } + } + + public func getRealtimeSuggestedCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent( + editorContent: editorContent, + isRealtimeSuggestionRelatedCommand: true, + withReply: reply + ) { handler, editor in + try await handler.presentRealtimeSuggestions(editor: editor) + } + } + + public func prefetchRealtimeSuggestions( + editorContent: Data, + withReply reply: @escaping () -> Void + ) { + // We don't need to wait for this. + reply() + + replyWithUpdatedContent( + editorContent: editorContent, + isRealtimeSuggestionRelatedCommand: true, + withReply: { _, _ in } + ) { handler, editor in + try await handler.generateRealtimeSuggestions(editor: editor) + } + } + + public func openChat( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + let handler = PseudoCommandHandler() + handler.openChat(forceDetach: false) + reply(nil, nil) + } + + public func promptToCode( + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.promptToCode(editor: editor) + } + } + + public func customCommand( + id: String, + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) { + replyWithUpdatedContent(editorContent: editorContent, withReply: reply) { handler, editor in + try await handler.customCommand(id: id, editor: editor) + } + } + + // MARK: - Settings + + public func toggleRealtimeSuggestion(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: \.realtimeSuggestionToggle) + UserDefaults.shared.set(on, for: \.realtimeSuggestionToggle) + Task { @MainActor in + Service.shared.guiController.store + .send(.suggestionWidget(.toastPanel(.toast(.toast( + "Real-time suggestion is turned \(on ? "on" : "off")", + .info, + nil + ))))) + } + reply(nil) + } + } + + public func postNotification(name: String, withReply reply: @escaping () -> Void) { + reply() + NotificationCenter.default.post(name: .init(name), object: nil) + } + + // MARK: - Requests + + public func send( + endpoint: String, + requestBody: Data, + reply: @escaping (Data?, Error?) -> Void + ) { + Service.shared.handleXPCServiceRequests( + endpoint: endpoint, + requestBody: requestBody, + reply: reply + ) + } +} + +struct NoAccessToAccessibilityAPIError: Error, LocalizedError { + var errorDescription: String? { + "Accessibility API permission is not granted. Please enable in System Settings.app." + } + + init() {} +} + diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Core/Sources/SuggestionInjector/SuggestionInjector.swift new file mode 100644 index 0000000..df78acf --- /dev/null +++ b/Core/Sources/SuggestionInjector/SuggestionInjector.swift @@ -0,0 +1,234 @@ +import Foundation +import SuggestionBasic + +// NOTE: Every lines from Xcode Extension has a line break at its end, even the last line. +// NOTE: Copilot's completion always start at character 0, no matter where the cursor is. + +public struct SuggestionInjector { + public init() {} + + public struct ExtraInfo { + public var didChangeContent = false + public var didChangeCursorPosition = false + public var suggestionRange: ClosedRange? + public var modifications: [Modification] = [] + public init() {} + } + + public func acceptSuggestion( + intoContentWithoutSuggestion content: inout [String], + cursorPosition: inout CursorPosition, + completion: CodeSuggestion, + extraInfo: inout ExtraInfo, + suggestionLineLimit: Int? = nil + ) { + extraInfo.didChangeContent = true + extraInfo.didChangeCursorPosition = true + extraInfo.suggestionRange = nil + let start = completion.range.start + let end = completion.range.end + let suggestionContent = completion.text + let lineEnding = if let ending = content.first?.last, ending.isNewline { + String(ending) + } else { + "\n" + } + + let firstRemovedLine = content[safe: start.line] + let lastRemovedLine = content[safe: end.line] + let startLine = max(0, start.line) + let endLine = max(start.line, min(end.line, content.endIndex - 1)) + if startLine < content.endIndex { + extraInfo.modifications.append(.deleted(startLine...endLine)) + content.removeSubrange(startLine...endLine) + } + + var toBeInserted = suggestionContent.breakLines( + proposedLineEnding: lineEnding, + appendLineBreakToLastLine: true + ) + + if let suggestionLineLimit { + let allLines = toBeInserted + toBeInserted = Array(toBeInserted.prefix(suggestionLineLimit)) + if suggestionLineLimit < allLines.count { + // advance to the next line when accepting part of a multi-line suggestion + toBeInserted.append(startOfLine(line: allLines[suggestionLineLimit], usingEnding: lineEnding)) + } + } + // prepending prefix text not in range if needed. + if let firstRemovedLine, + !firstRemovedLine.isEmptyOrNewLine, + start.character > 0, + start.character < firstRemovedLine.count, + !toBeInserted.isEmpty + { + let leftoverRange = firstRemovedLine.utf16.startIndex..<(firstRemovedLine.utf16.index( + firstRemovedLine.utf16.startIndex, + offsetBy: start.character, + limitedBy: firstRemovedLine.utf16.endIndex + ) ?? firstRemovedLine.utf16.endIndex) + var leftover = String(firstRemovedLine[leftoverRange]) + if leftover.last?.isNewline ?? false { + leftover.removeLast(1) + } + toBeInserted[0].insert( + contentsOf: leftover, + at: toBeInserted[0].startIndex + ) + } + + let recoveredSuffixLength = recoverSuffixIfNeeded( + endOfReplacedContent: end, + toBeInserted: &toBeInserted, + lastRemovedLine: lastRemovedLine, + lineEnding: lineEnding + ) + + let cursorCol = toBeInserted[toBeInserted.endIndex - 1].utf16.count + - 1 - recoveredSuffixLength + let insertingIndex = min(start.line, content.endIndex) + content.insert(contentsOf: toBeInserted, at: insertingIndex) + extraInfo.modifications.append(.inserted(insertingIndex, toBeInserted)) + cursorPosition = .init( + line: startLine + toBeInserted.count - 1, + character: max(0, cursorCol) + ) + } + + func startOfLine(line: String, usingEnding lineEnding: String) -> String { + return line.prefix(while: { $0.isWhitespace }).appending(lineEnding) + } + + func recoverSuffixIfNeeded( + endOfReplacedContent end: CursorPosition, + toBeInserted: inout [String], + lastRemovedLine: String?, + lineEnding: String + ) -> Int { + // If there is no line removed, there is no need to recover anything. + guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 } + + let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak() + + // If the replaced range covers the whole line, return immediately. + guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.utf16.count + else { return 0 } + + // if we are not inserting anything, return immediately. + guard !toBeInserted.isEmpty, + let first = toBeInserted.first?.droppedLineBreak(), !first.isEmpty, + let last = toBeInserted.last?.droppedLineBreak(), !last.isEmpty + else { return 0 } + + // case 1: user keeps typing as the suggestion suggests. + + if first.hasPrefix(lastRemovedLineCleaned) { + return 0 + } + + // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode) + + // locate the split index, the prefix of which matches the suggestion prefix. + var splitIndex: String.Index? + + for offset in end.character..` + + let regex = try! NSRegularExpression(pattern: "\\s*?<#.*?#>") + + if let firstPlaceholderRange = regex.firstMatch( + in: suffix, + options: [], + range: NSRange(suffix.startIndex..., in: suffix) + )?.range, + firstPlaceholderRange.location == 0, + let r = Range(firstPlaceholderRange, in: suffix) + { + suffix.removeSubrange(r) + } + + let lastInsertingLine = toBeInserted[toBeInserted.endIndex - 1] + .droppedLineBreak() + .appending(suffix) + .recoveredLineBreak(lineEnding: lineEnding) + + toBeInserted[toBeInserted.endIndex - 1] = lastInsertingLine + + return suffix.utf16.count + } +} + +public struct SuggestionAnalyzer { + struct Result { + enum InsertPostion { + case currentLine + case nextLine + } + + var insertPosition: InsertPostion + var commonPrefix: String? + } + + func analyze() -> Result { + fatalError() + } +} + +extension String { + var isEmptyOrNewLine: Bool { + isEmpty || self == "\n" || self == "\r\n" || self == "\r" + } + + func droppedLineBreak() -> String { + if last?.isNewline ?? false { + return String(dropLast(1)) + } + return self + } + + func recoveredLineBreak(lineEnding: String) -> String { + if hasSuffix(lineEnding) { + return self + } + return self + lineEnding + } +} + +func longestCommonPrefix(of a: String, and b: String) -> String { + let length = min(a.count, b.count) + + var prefix = "" + for i in 0.. Element? { + indices.contains(index) ? self[index] : nil + } +} + diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift new file mode 100644 index 0000000..2802d78 --- /dev/null +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -0,0 +1,86 @@ +import BuiltinExtension +import struct CopilotForXcodeKit.WorkspaceInfo +import Foundation +import GitHubCopilotService +import Preferences +import SuggestionBasic +import SuggestionProvider +import UserDefaultsObserver +import Workspace + +public protocol SuggestionServiceType: SuggestionServiceProvider {} + +public actor SuggestionService: SuggestionServiceType { + public var configuration: SuggestionProvider.SuggestionServiceConfiguration { + get async { await suggestionProvider.configuration } + } + + let middlewares: [SuggestionServiceMiddleware] + + let suggestionProvider: SuggestionServiceProvider + + public init( + provider: any SuggestionServiceProvider, + middlewares: [SuggestionServiceMiddleware] = SuggestionServiceMiddlewareContainer + .middlewares + ) { + suggestionProvider = provider + self.middlewares = middlewares + } + + public static func service( + for serviceType: SuggestionFeatureProvider = UserDefaults.shared + .value(for: \.suggestionFeatureProvider) + ) -> SuggestionService { + switch serviceType { + case .builtIn(.gitHubCopilot), .extension: + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: GitHubCopilotExtension.self + ) + return SuggestionService(provider: provider) + } + } +} + +public extension SuggestionService { + func getSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [SuggestionBasic.CodeSuggestion] { + var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:) + let configuration = await configuration + + for middleware in middlewares.reversed() { + getSuggestion = { [getSuggestion] request, workspaceInfo in + try await middleware.getSuggestion( + request, + configuration: configuration, + next: { [getSuggestion] request in + try await getSuggestion(request, workspaceInfo) + } + ) + } + } + + return try await getSuggestion(request, workspaceInfo) + } + + func notifyAccepted( + _ suggestion: SuggestionBasic.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo) + } + + func notifyRejected( + _ suggestions: [SuggestionBasic.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo) + } + + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async { + await suggestionProvider.cancelRequest(workspaceInfo: workspaceInfo) + } +} + diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift new file mode 100644 index 0000000..2745089 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift @@ -0,0 +1,107 @@ +import AppKit +import ChatTab +import ComposableArchitecture +import Foundation +import SwiftUI + +final class ChatPanelWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + private let storeObserver = NSObject() + + var minimizeWindow: () -> Void = {} + + init( + store: StoreOf, + chatTabPool: ChatTabPool, + minimizeWindow: @escaping () -> Void + ) { + self.minimizeWindow = minimizeWindow + super.init( + contentRect: .zero, + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + titleVisibility = .hidden + addTitlebarAccessoryViewController({ + let controller = NSTitlebarAccessoryViewController() + let view = NSHostingView(rootView: ChatTitleBar(store: store)) + controller.view = view + view.frame = .init(x: 0, y: 0, width: 100, height: 40) + controller.layoutAttribute = .right + return controller + }()) + titlebarAppearsTransparent = true + isReleasedWhenClosed = false + isOpaque = false + backgroundColor = .clear + level = .init(NSWindow.Level.floating.rawValue + 1) + collectionBehavior = [ + .fullScreenAuxiliary, + .transient, + .fullScreenPrimary, + .fullScreenAllowsTiling, + ] + hasShadow = true + contentView = NSHostingView( + rootView: ChatWindowView( + store: store, + toggleVisibility: { [weak self] isDisplayed in + guard let self else { return } + self.isPanelDisplayed = isDisplayed + } + ) + .environment(\.chatTabPool, chatTabPool) + ) + setIsVisible(true) + isPanelDisplayed = false + + storeObserver.observe { [weak self] in + guard let self else { return } + let isDetached = store.isDetached + Task { @MainActor in + if UserDefaults.shared.value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) { + self.setFloatOnTop(!isDetached) + } else { + self.setFloatOnTop(true) + } + } + } + } + + func setFloatOnTop(_ isFloatOnTop: Bool) { + let targetLevel: NSWindow.Level = isFloatOnTop + ? .init(NSWindow.Level.floating.rawValue + 1) + : .normal + + if targetLevel != level { + level = targetLevel + } + } + + var isWindowHidden: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + var isPanelDisplayed: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + override var alphaValue: CGFloat { + didSet { + ignoresMouseEvents = alphaValue <= 0 + } + } + + override func miniaturize(_: Any?) { + minimizeWindow() + } +} + diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift new file mode 100644 index 0000000..f9f68a4 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -0,0 +1,425 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import SwiftUI + +private let r: Double = 8 + +struct ChatWindowView: View { + let store: StoreOf + let toggleVisibility: (Bool) -> Void + + var body: some View { + WithPerceptionTracking { + let _ = store.chatTabGroup.selectedTabId // force re-evaluation + VStack(spacing: 0) { + Rectangle().fill(.regularMaterial).frame(height: 28) + + Divider() + + ChatTabBar(store: store) + .frame(height: 26) + + Divider() + + ChatTabContainer(store: store) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .xcodeStyleFrame(cornerRadius: 10) + .ignoresSafeArea(edges: .top) + .onChange(of: store.isPanelDisplayed) { isDisplayed in + toggleVisibility(isDisplayed) + } + .preferredColorScheme(store.colorScheme) + } + } +} + +struct ChatTitleBar: View { + let store: StoreOf + @State var isHovering = false + + var body: some View { + WithPerceptionTracking { + HStack(spacing: 6) { + Button(action: { + store.send(.closeActiveTabClicked) + }) { + EmptyView() + } + .opacity(0) + .keyboardShortcut("w", modifiers: [.command]) + + Button( + action: { + store.send(.hideButtonClicked) + } + ) { + Image(systemName: "minus") + .foregroundStyle(.black.opacity(0.5)) + .font(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) + } + ) { + 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) + .padding(.trailing, 8) + .onHover(perform: { hovering in + isHovering = hovering + }) + } + } + + struct TrafficLightButton: View { + let isHovering: Bool + let isActive: Bool + let color: Color + let action: () -> Void + let icon: () -> Icon + + @Environment(\.controlActiveState) var controlActiveState + + var body: some View { + Button(action: { + action() + }) { + Circle() + .fill( + controlActiveState == .key && isActive + ? color + : Color(nsColor: .separatorColor) + ) + .frame( + width: Style.trafficLightButtonSize, + height: Style.trafficLightButtonSize + ) + .overlay { + Circle().stroke(lineWidth: 0.5).foregroundColor(.black.opacity(0.2)) + } + .overlay { + if isHovering { + icon() + } + } + } + .focusable(false) + } + } +} + +private extension View { + func hideScrollIndicator() -> some View { + if #available(macOS 13.0, *) { + return scrollIndicators(.hidden) + } else { + return self + } + } +} + +struct ChatTabBar: View { + let store: StoreOf + + struct TabBarState: Equatable { + var tabInfo: IdentifiedArray + var selectedTabId: String + } + + 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]) + } + } + + 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 + ?? "" + ScrollViewReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(tabInfo, id: \.id) { info in + if let tab = chatTabPool.getTab(of: info.id) { + ChatTabBarButton( + store: store, + info: info, + content: { tab.tabItem }, + icon: { tab.icon }, + isSelected: info.id == selectedTabId + ) + .contextMenu { + 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() + } + } + } + } + .hideScrollIndicator() + .onChange(of: selectedTabId) { id in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(id) + } + } + } + } + } + } + + struct CreateButton: 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? + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + draggingTabId = nil + return true + } + + 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)) + } +} + +struct ChatTabBarButton: View { + let store: StoreOf + let info: ChatTabInfo + let content: () -> Content + let icon: () -> Icon + let isSelected: Bool + @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) + } + .buttonStyle(.plain) + .padding(2) + .padding(.leading, 8) + .opacity(isHovered ? 1 : 0) + } + .onHover { isHovered = $0 } + .animation(.linear(duration: 0.1), value: isHovered) + .animation(.linear(duration: 0.1), value: isSelected) + + Divider().padding(.vertical, 6) + } + .background(isSelected ? Color(nsColor: .selectedControlColor) : Color.clear) + .frame(maxHeight: .infinity) + } +} + +struct ChatTabContainer: View { + let store: StoreOf + @Environment(\.chatTabPool) var chatTabPool + + var body: some View { + WithPerceptionTracking { + let tabInfo = store.chatTabGroup.tabInfo + let selectedTabId = store.chatTabGroup.selectedTabId + ?? store.chatTabGroup.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() + } + } + } + } + } + } +} + +struct CreateOtherChatTabMenuStyle: MenuStyle { + func makeBody(configuration: Configuration) -> some View { + Image(systemName: "chevron.down") + .resizable() + .frame(width: 7, height: 4) + .frame(maxHeight: .infinity) + .padding(.leading, 4) + .padding(.trailing, 8) + .foregroundColor(.secondary) + } +} + +struct ChatWindowView_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"), + "7": EmptyChatTab(id: "7"), + ]) + + 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" + ), + isPanelDisplayed: true + ), + reducer: { ChatPanelFeature() } + ) + } + + static var previews: some View { + ChatWindowView(store: createStore(), toggleVisibility: { _ in }) + .xcodeStyleFrame() + .padding() + .environment(\.chatTabPool, pool) + } +} + diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift new file mode 100644 index 0000000..35f7432 --- /dev/null +++ b/Core/Sources/SuggestionWidget/CursorPositionTracker.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import Perception +import SuggestionBasic +import XcodeInspector + +@Perceptible +final class CursorPositionTracker { + @MainActor + var cursorPosition: CursorPosition = .zero + + @PerceptionIgnored var editorObservationTask: Set = [] + @PerceptionIgnored var eventObservationTask: Task? + + init() { + observeAppChange() + } + + deinit { + eventObservationTask?.cancel() + } + + private func observeAppChange() { + editorObservationTask = [] + Task { + await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in + guard let editor, let self else { return } + Task { @MainActor in + self.observeAXNotifications(editor) + } + }.store(in: &editorObservationTask) + } + } + + private func observeAXNotifications(_ editor: SourceEditor) { + eventObservationTask?.cancel() + let content = editor.getLatestEvaluatedContent() + Task { @MainActor in + self.cursorPosition = content.cursorPosition + } + eventObservationTask = Task { [weak self] in + for await event in await editor.axNotifications.notifications() { + guard let self else { return } + guard event.kind == .evaluatedContentChanged else { continue } + let content = editor.getLatestEvaluatedContent() + Task { @MainActor in + self.cursorPosition = content.cursorPosition + } + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift new file mode 100644 index 0000000..990b557 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -0,0 +1,297 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import GitHubCopilotService +import SwiftUI + +public enum ChatTabBuilderCollection: Equatable { + case folder(title: String, kinds: [ChatTabKind]) + case kind(ChatTabKind) +} + +public struct ChatTabKind: Equatable { + public var builder: any ChatTabBuilder + var title: String { builder.title } + + public init(_ builder: any ChatTabBuilder) { + self.builder = builder + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + } +} + +@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] + } + + init( + tabInfo: IdentifiedArray = [], + tabCollection: [ChatTabBuilderCollection] = [], + selectedTabId: String? = nil + ) { + self.tabInfo = tabInfo + self.tabCollection = tabCollection + self.selectedTabId = selectedTabId + } + } + + @ObservableState + public struct State: Equatable { + public var chatTabGroup = ChatTabGroup() + var colorScheme: ColorScheme = .light + public internal(set) var isPanelDisplayed = false + var isDetached = false + var isFullScreen = false + } + + public enum Action: Equatable { + // Window + case hideButtonClicked + case closeActiveTabClicked + case toggleChatPanelDetachedButtonClicked + case detachChatPanel + case attachChatPanel + case enterFullScreen + case exitFullScreen + case presentChatPanel(forceDetach: Bool) + + // Tabs + case updateChatTabInfo(IdentifiedArray) + case createNewTapButtonHovered + case closeTabButtonClicked(id: String) + case createNewTapButtonClicked(kind: ChatTabKind?) + case tabClicked(id: String) + case appendAndSelectTab(ChatTabInfo) + case switchToNextTab + case switchToPreviousTab + case moveChatTab(from: Int, to: Int) + case focusActiveChatTab + + case chatTab(id: String, action: ChatTabItem.Action) + } + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activatePreviousActiveXcode) var activatePreviouslyActiveXcode + @Dependency(\.activateThisApp) var activateExtensionService + @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection + + @MainActor func toggleFullScreen() { + let window = suggestionWidgetControllerDependency.windowsController?.windows + .chatPanelWindow + window?.toggleFullScreen(nil) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .hideButtonClicked: + state.isPanelDisplayed = false + + if state.isFullScreen { + return .run { _ in + await MainActor.run { toggleFullScreen() } + activatePreviouslyActiveXcode() + } + } + + return .run { _ in + activatePreviouslyActiveXcode() + } + + case .closeActiveTabClicked: + if let id = state.chatTabGroup.selectedTabId { + return .run { send in + await send(.closeTabButtonClicked(id: id)) + } + } + + state.isPanelDisplayed = false + return .none + + case .toggleChatPanelDetachedButtonClicked: + if state.isFullScreen, state.isDetached { + return .run { send in + await send(.attachChatPanel) + } + } + + state.isDetached.toggle() + return .none + + case .detachChatPanel: + state.isDetached = true + return .none + + case .attachChatPanel: + if state.isFullScreen { + return .run { send in + await MainActor.run { toggleFullScreen() } + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.attachChatPanel) + } + } + + state.isDetached = false + return .none + + case .enterFullScreen: + state.isFullScreen = true + return .run { send in + await send(.detachChatPanel) + } + + case .exitFullScreen: + state.isFullScreen = false + return .none + + case let .presentChatPanel(forceDetach): + if forceDetach { + state.isDetached = true + } + state.isPanelDisplayed = true + return .run { send in + 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 + } + } + return .none + + case let .closeTabButtonClicked(id): + let firstIndex = state.chatTabGroup.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 { + state.isPanelDisplayed = false + } + if nextIndex < state.chatTabGroup.tabInfo.count { + state.chatTabGroup.selectedTabId = state.chatTabGroup.tabInfo[nextIndex].id + } else { + state.chatTabGroup.selectedTabId = nil + } + return .none + + case .createNewTapButtonHovered: + state.chatTabGroup.tabCollection = chatTabBuilderCollection() + return .none + + case .createNewTapButtonClicked: + return .none // handled elsewhere + + case let .tabClicked(id): + guard state.chatTabGroup.tabInfo.contains(where: { $0.id == id }) else { + state.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 + return .run { send in + await send(.focusActiveChatTab) + } + + 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 + guard let id else { return .none } + return .run { send in + await send(.chatTab(id: id, action: .focus)) + } + + case let .chatTab(id, .close): + return .run { send in + await send(.closeTabButtonClicked(id: id)) + } + + case .chatTab: + return .none + } + }.forEach(\.chatTabGroup.tabInfo, action: /Action.chatTab) { + ChatTabItem() + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift new file mode 100644 index 0000000..51b7d91 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift @@ -0,0 +1,83 @@ +import ActiveApplicationMonitor +import ComposableArchitecture +import Preferences +import SuggestionBasic +import SwiftUI + +@Reducer +public struct CircularWidgetFeature { + public struct IsProcessingCounter: Equatable { + var expirationDate: TimeInterval + } + + @ObservableState + public struct State: Equatable { + var isProcessingCounters = [IsProcessingCounter]() + var isProcessing: Bool + var isDisplayingContent: Bool + var isContentEmpty: Bool + var isChatPanelDetached: Bool + var isChatOpen: Bool + } + + public enum Action: Equatable { + case widgetClicked + case detachChatPanelToggleClicked + case openChatButtonClicked + case runCustomCommandButtonClicked(CustomCommand) + case markIsProcessing + case endIsProcessing + case _forceEndIsProcessing + } + + struct CancelAutoEndIsProcessKey: Hashable {} + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .detachChatPanelToggleClicked: + return .none // handled elsewhere + + case .openChatButtonClicked: + return .run { _ in + suggestionWidgetControllerDependency.onOpenChatClicked() + } + + case let .runCustomCommandButtonClicked(command): + return .run { _ in + suggestionWidgetControllerDependency.onCustomCommandClicked(command) + } + + case .widgetClicked: + return .none // handled elsewhere + + case .markIsProcessing: + let deadline = Date().timeIntervalSince1970 + 20 + state.isProcessingCounters.append(IsProcessingCounter(expirationDate: deadline)) + state.isProcessing = true + return .run { send in + try await Task.sleep(nanoseconds: 20 * 1_000_000_000) + try Task.checkCancellation() + await send(._forceEndIsProcessing) + }.cancellable(id: CancelAutoEndIsProcessKey(), cancelInFlight: true) + + case .endIsProcessing: + if !state.isProcessingCounters.isEmpty { + state.isProcessingCounters.removeFirst() + } + state.isProcessingCounters + .removeAll(where: { $0.expirationDate < Date().timeIntervalSince1970 }) + state.isProcessing = !state.isProcessingCounters.isEmpty + return .none + + case ._forceEndIsProcessing: + state.isProcessingCounters.removeAll() + state.isProcessing = false + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift new file mode 100644 index 0000000..1e3f3dc --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -0,0 +1,156 @@ +import AppKit +import ComposableArchitecture +import Foundation + +@Reducer +public struct PanelFeature { + @ObservableState + public struct State: Equatable { + public var content: SharedPanelFeature.Content { + get { sharedPanelState.content } + set { + sharedPanelState.content = newValue + suggestionPanelState.content = newValue.suggestion + } + } + + // MARK: SharedPanel + + var sharedPanelState = SharedPanelFeature.State() + + // MARK: SuggestionPanel + + var suggestionPanelState = SuggestionPanelFeature.State() + } + + public enum Action: Equatable { + case presentSuggestion + case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool) + case presentError(String) + case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState) + case displayPanelContent + case expandSuggestion + case discardSuggestion + case removeDisplayedContent + case switchToAnotherEditorAndUpdateContent + case hidePanel + case showPanel + + case sharedPanel(SharedPanelFeature.Action) + case suggestionPanel(SuggestionPanelFeature.Action) + } + + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.activateThisApp) var activateThisApp + var windows: WidgetWindows? { suggestionWidgetControllerDependency.windowsController?.windows } + + public var body: some ReducerOf { + Scope(state: \.suggestionPanelState, action: \.suggestionPanel) { + SuggestionPanelFeature() + } + + Scope(state: \.sharedPanelState, action: \.sharedPanel) { + SharedPanelFeature() + } + + Reduce { state, action in + switch action { + case .presentSuggestion: + return .run { send in + guard let fileURL = await xcodeInspector.safe.activeDocumentURL, + let provider = await fetchSuggestionProvider(fileURL: fileURL) + else { return } + await send(.presentSuggestionProvider(provider, displayContent: true)) + } + + case let .presentSuggestionProvider(provider, displayContent): + state.content.suggestion = provider + if displayContent { + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + } + return .none + + case let .presentError(errorDescription): + state.content.error = errorDescription + return .run { send in + await send(.displayPanelContent) + }.animation(.easeInOut(duration: 0.2)) + + case let .presentPromptToCode(initialState): + return .run { send in + await send(.sharedPanel(.promptToCodeGroup(.createPromptToCode(initialState)))) + } + + case .displayPanelContent: + if !state.sharedPanelState.isEmpty { + state.sharedPanelState.isPanelDisplayed = true + } + + if state.suggestionPanelState.content != nil { + state.suggestionPanelState.isPanelDisplayed = true + } + + return .none + + case .discardSuggestion: + state.content.suggestion = nil + return .none + case .expandSuggestion: + state.content.isExpanded = true + return .none + case .switchToAnotherEditorAndUpdateContent: + return .run { send in + guard let fileURL = await xcodeInspector.safe.realtimeActiveDocumentURL + else { return } + + await send(.sharedPanel( + .promptToCodeGroup( + .updateActivePromptToCode(documentURL: fileURL) + ) + )) + } + case .hidePanel: + state.suggestionPanelState.isPanelDisplayed = false + return .none + case .showPanel: + state.suggestionPanelState.isPanelDisplayed = true + return .none + case .removeDisplayedContent: + state.content.error = nil + state.content.suggestion = nil + return .none + + case .sharedPanel(.promptToCodeGroup(.activateOrCreatePromptToCode)), + .sharedPanel(.promptToCodeGroup(.createPromptToCode)): + let hasPromptToCode = state.content.promptToCode != nil + return .run { send in + await send(.displayPanelContent) + + if hasPromptToCode { + activateThisApp() + await MainActor.run { + windows?.sharedPanelWindow.makeKey() + } + } + }.animation(.easeInOut(duration: 0.2)) + + case .sharedPanel: + return .none + + case .suggestionPanel: + return .none + } + } + } + + func fetchSuggestionProvider(fileURL: URL) async -> CodeSuggestionProvider? { + guard let provider = await suggestionWidgetControllerDependency + .suggestionWidgetDataSource? + .suggestionForFile(at: fileURL) else { return nil } + return provider + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift new file mode 100644 index 0000000..9ba5cad --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift @@ -0,0 +1,276 @@ +import AppKit +import ComposableArchitecture +import CustomAsyncAlgorithms +import Dependencies +import Foundation +import PromptToCodeService +import SuggestionBasic + +public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey { + public static let liveValue: (PromptToCode.State) -> Void = { _ in + assertionFailure("Please provide a handler") + } + + public static let previewValue: (PromptToCode.State) -> Void = { _ in + print("Accept Prompt to Code") + } +} + +public extension DependencyValues { + var promptToCodeAcceptHandler: (PromptToCode.State) -> Void { + get { self[PromptToCodeAcceptHandlerDependencyKey.self] } + set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue } + } +} + +@Reducer +public struct PromptToCode { + @ObservableState + public struct State: Equatable, Identifiable { + public indirect enum HistoryNode: Equatable { + case empty + case node(code: String, description: String, previous: HistoryNode) + + mutating func enqueue(code: String, description: String) { + let current = self + self = .node(code: code, description: description, previous: current) + } + + mutating func pop() -> (code: String, description: String)? { + switch self { + case .empty: + return nil + case let .node(code, description, previous): + self = previous + return (code, description) + } + } + } + + public enum FocusField: Equatable { + case textField + } + + public var id: URL { documentURL } + public var history: HistoryNode + public var code: String + public var isResponding: Bool + public var description: String + public var error: String? + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var projectRootURL: URL + public var documentURL: URL + public var allCode: String + public var allLines: [String] + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + public var commandName: String? + public var prompt: String + public var isContinuous: Bool + public var isAttachedToSelectionRange: Bool + public var focusedField: FocusField? = .textField + + public var filename: String { documentURL.lastPathComponent } + public var canRevert: Bool { history != .empty } + + public init( + code: String, + prompt: String, + language: CodeLanguage, + indentSize: Int, + usesTabsForIndentation: Bool, + projectRootURL: URL, + documentURL: URL, + allCode: String, + allLines: [String], + commandName: String? = nil, + description: String = "", + isResponding: Bool = false, + isAttachedToSelectionRange: Bool = true, + error: String? = nil, + history: HistoryNode = .empty, + isContinuous: Bool = false, + selectionRange: CursorRange? = nil, + extraSystemPrompt: String? = nil, + generateDescriptionRequirement: Bool? = nil + ) { + self.history = history + self.code = code + self.prompt = prompt + self.isResponding = isResponding + self.description = description + self.error = error + self.isContinuous = isContinuous + self.selectionRange = selectionRange + self.language = language + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.projectRootURL = projectRootURL + self.documentURL = documentURL + self.allCode = allCode + self.allLines = allLines + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + self.isAttachedToSelectionRange = isAttachedToSelectionRange + self.commandName = commandName + + if selectionRange?.isEmpty ?? true { + self.isAttachedToSelectionRange = false + } + } + } + + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case focusOnTextField + case selectionRangeToggleTapped + case modifyCodeButtonTapped + case revertButtonTapped + case stopRespondingButtonTapped + case modifyCodeFinished + case modifyCodeChunkReceived(code: String, description: String) + case modifyCodeFailed(error: String) + case modifyCodeCancelled + case cancelButtonTapped + case acceptButtonTapped + case copyCodeButtonTapped + case appendNewLineToPromptButtonTapped + } + + @Dependency(\.promptToCodeService) var promptToCodeService + @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler + + enum CancellationKey: Hashable { + case modifyCode(State.ID) + } + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .focusOnTextField: + state.focusedField = .textField + return .none + + case .selectionRangeToggleTapped: + state.isAttachedToSelectionRange.toggle() + return .none + + case .modifyCodeButtonTapped: + guard !state.isResponding else { return .none } + let copiedState = state + state.history.enqueue(code: state.code, description: state.description) + state.isResponding = true + state.code = "" + state.description = "" + state.error = nil + + return .run { send in + do { + let stream = try await promptToCodeService.modifyCode( + code: copiedState.code, + requirement: copiedState.prompt, + source: .init( + language: copiedState.language, + documentURL: copiedState.documentURL, + projectRootURL: copiedState.projectRootURL, + content: copiedState.allCode, + lines: copiedState.allLines, + range: copiedState.selectionRange ?? .outOfScope + ), + isDetached: !copiedState.isAttachedToSelectionRange, + extraSystemPrompt: copiedState.extraSystemPrompt, + generateDescriptionRequirement: copiedState + .generateDescriptionRequirement + ).timedDebounce(for: 0.2) + + for try await fragment in stream { + try Task.checkCancellation() + await send(.modifyCodeChunkReceived( + code: fragment.code, + description: fragment.description + )) + } + try Task.checkCancellation() + await send(.modifyCodeFinished) + } catch is CancellationError { + try Task.checkCancellation() + await send(.modifyCodeCancelled) + } catch { + try Task.checkCancellation() + if (error as NSError).code == NSURLErrorCancelled { + await send(.modifyCodeCancelled) + return + } + + await send(.modifyCodeFailed(error: error.localizedDescription)) + } + }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true) + + case .revertButtonTapped: + guard let (code, description) = state.history.pop() else { return .none } + state.code = code + state.description = description + return .none + + case .stopRespondingButtonTapped: + state.isResponding = false + promptToCodeService.stopResponding() + return .cancel(id: CancellationKey.modifyCode(state.id)) + + case let .modifyCodeChunkReceived(code, description): + state.code = code + state.description = description + return .none + + case .modifyCodeFinished: + state.prompt = "" + state.isResponding = false + if state.code.isEmpty, state.description.isEmpty { + // if both code and description are empty, we treat it as failed + return .run { send in + await send(.revertButtonTapped) + } + } + + return .none + + case let .modifyCodeFailed(error): + state.error = error + state.isResponding = false + return .run { send in + await send(.revertButtonTapped) + } + + case .modifyCodeCancelled: + state.isResponding = false + return .none + + case .cancelButtonTapped: + promptToCodeService.stopResponding() + return .none + + case .acceptButtonTapped: + promptToCodeAcceptHandler(state) + return .none + + case .copyCodeButtonTapped: + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(state.code, forType: .string) + return .none + + case .appendNewLineToPromptButtonTapped: + state.prompt += "\n" + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift new file mode 100644 index 0000000..b961779 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift @@ -0,0 +1,188 @@ +import ComposableArchitecture +import Foundation +import PromptToCodeService +import SuggestionBasic +import XcodeInspector + +@Reducer +public struct PromptToCodeGroup { + @ObservableState + public struct State: Equatable { + public var promptToCodes: IdentifiedArrayOf = [] + public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared + .realtimeActiveDocumentURL + public var activePromptToCode: PromptToCode.State? { + get { + if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) { + return detached + } + guard let id = activeDocumentURL else { return nil } + return promptToCodes[id: id] + } + set { + if let id = newValue?.id { + promptToCodes[id: id] = newValue + } + } + } + } + + public struct PromptToCodeInitialState: Equatable { + public var code: String + public var selectionRange: CursorRange? + public var language: CodeLanguage + public var identSize: Int + public var usesTabsForIndentation: Bool + public var documentURL: URL + public var projectRootURL: URL + public var allCode: String + public var allLines: [String] + public var isContinuous: Bool + public var commandName: String? + public var defaultPrompt: String + public var extraSystemPrompt: String? + public var generateDescriptionRequirement: Bool? + + public init( + code: String, + selectionRange: CursorRange?, + language: CodeLanguage, + identSize: Int, + usesTabsForIndentation: Bool, + documentURL: URL, + projectRootURL: URL, + allCode: String, + allLines: [String], + isContinuous: Bool, + commandName: String?, + defaultPrompt: String, + extraSystemPrompt: String?, + generateDescriptionRequirement: Bool? + ) { + self.code = code + self.selectionRange = selectionRange + self.language = language + self.identSize = identSize + self.usesTabsForIndentation = usesTabsForIndentation + self.documentURL = documentURL + self.projectRootURL = projectRootURL + self.allCode = allCode + self.allLines = allLines + self.isContinuous = isContinuous + self.commandName = commandName + self.defaultPrompt = defaultPrompt + self.extraSystemPrompt = extraSystemPrompt + self.generateDescriptionRequirement = generateDescriptionRequirement + } + } + + public enum Action: Equatable { + /// Activate the prompt to code if it exists or create it if it doesn't + case activateOrCreatePromptToCode(PromptToCodeInitialState) + case createPromptToCode(PromptToCodeInitialState) + case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange) + case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID) + case updateActivePromptToCode(documentURL: URL) + case discardExpiredPromptToCode(documentURLs: [URL]) + case promptToCode(PromptToCode.State.ID, PromptToCode.Action) + case activePromptToCode(PromptToCode.Action) + } + + @Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory + @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .activateOrCreatePromptToCode(s): + if let promptToCode = state.activePromptToCode { + return .run { send in + await send(.promptToCode(promptToCode.id, .focusOnTextField)) + } + } + return .run { send in + await send(.createPromptToCode(s)) + } + case let .createPromptToCode(s): + let newPromptToCode = PromptToCode.State( + code: s.code, + prompt: s.defaultPrompt, + language: s.language, + indentSize: s.identSize, + usesTabsForIndentation: s.usesTabsForIndentation, + projectRootURL: s.projectRootURL, + documentURL: s.documentURL, + allCode: s.allCode, + allLines: s.allLines, + commandName: s.commandName, + isContinuous: s.isContinuous, + selectionRange: s.selectionRange, + extraSystemPrompt: s.extraSystemPrompt, + generateDescriptionRequirement: s.generateDescriptionRequirement + ) + // insert at 0 so it has high priority then the other detached prompt to codes + state.promptToCodes.insert(newPromptToCode, at: 0) + return .run { send in + if !newPromptToCode.prompt.isEmpty { + await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped)) + } + }.cancellable( + id: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id), + cancelInFlight: true + ) + + case let .updatePromptToCodeRange(id, range): + if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange { + state.promptToCodes[id: id]?.selectionRange = range + } + return .none + + case let .discardAcceptedPromptToCodeIfNotContinuous(id): + state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous } + return .none + + case let .updateActivePromptToCode(documentURL): + state.activeDocumentURL = documentURL + return .none + + case let .discardExpiredPromptToCode(documentURLs): + for url in documentURLs { + state.promptToCodes.remove(id: url) + } + return .none + + case .promptToCode: + return .none + + case .activePromptToCode: + return .none + } + } + .ifLet(\.activePromptToCode, action: \.activePromptToCode) { + PromptToCode() + .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + } + .forEach(\.promptToCodes, action: /Action.promptToCode, element: { + PromptToCode() + .dependency(\.promptToCodeService, promptToCodeServiceFactory()) + }) + + Reduce { state, action in + switch action { + case let .promptToCode(id, .cancelButtonTapped): + state.promptToCodes.remove(id: id) + return .run { _ in + activatePreviousActiveXcode() + } + case .activePromptToCode(.cancelButtonTapped): + guard let id = state.activePromptToCode?.id else { return .none } + state.promptToCodes.remove(id: id) + return .run { _ in + activatePreviousActiveXcode() + } + default: return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift new file mode 100644 index 0000000..a3a2284 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift @@ -0,0 +1,58 @@ +import ComposableArchitecture +import Preferences +import SwiftUI + +@Reducer +public struct SharedPanelFeature { + public struct Content: Equatable { + public var promptToCodeGroup = PromptToCodeGroup.State() + var suggestion: CodeSuggestionProvider? + var isExpanded: Bool = false + public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode } + var error: String? + } + + @ObservableState + public struct State: Equatable { + var content: Content = .init() + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var isPanelDisplayed: Bool = false + var isEmpty: Bool { + if content.error != nil { return false } + if content.promptToCode != nil { return false } + if content.suggestion != nil, + UserDefaults.shared + .value(for: \.suggestionPresentationMode) == .floatingWidget { return false } + return true + } + + var opacity: Double { + guard isPanelDisplayed else { return 0 } + guard !isEmpty else { return 0 } + return 1 + } + } + + public enum Action: Equatable { + case errorMessageCloseButtonTapped + case promptToCodeGroup(PromptToCodeGroup.Action) + } + + public var body: some ReducerOf { + Scope(state: \.content.promptToCodeGroup, action: \.promptToCodeGroup) { + PromptToCodeGroup() + } + + Reduce { state, action in + switch action { + case .errorMessageCloseButtonTapped: + state.content.error = nil + return .none + case .promptToCodeGroup: + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift new file mode 100644 index 0000000..82010df --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +@Reducer +public struct SuggestionPanelFeature { + @ObservableState + public struct State: Equatable { + var content: CodeSuggestionProvider? + var isExpanded: Bool = false + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + var firstLineIndent: Double = 0 + var lineHeight: Double = 17 + var isPanelDisplayed: Bool = false + var isPanelOutOfFrame: Bool = false + var opacity: Double { + guard isPanelDisplayed else { return 0 } + if isPanelOutOfFrame { return 0 } + guard content != nil else { return 0 } + return 1 + } + } + + public enum Action: Equatable { + case noAction + } + + public var body: some ReducerOf { + Reduce { _, _ in .none } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift new file mode 100644 index 0000000..14ac9d4 --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ToastPanel.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import Preferences +import SwiftUI +import Toast + +@Reducer +public struct ToastPanel { + @ObservableState + public struct State: Equatable { + var toast: Toast.State = .init() + var colorScheme: ColorScheme = .light + var alignTopToAnchor = false + } + + public enum Action: Equatable { + case start + case toast(Toast.Action) + } + + public var body: some ReducerOf { + Scope(state: \.toast, action: \.toast) { + Toast() + } + + Reduce { state, action in + switch action { + case .start: + return .run { send in + await send(.toast(.start)) + } + case .toast: + return .none + } + } + } +} diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift new file mode 100644 index 0000000..a15919f --- /dev/null +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -0,0 +1,396 @@ +import ActiveApplicationMonitor +import AppActivator +import AsyncAlgorithms +import ComposableArchitecture +import Foundation +import GitHubCopilotService +import Logger +import Preferences +import SwiftUI +import Toast +import XcodeInspector + +@Reducer +public struct WidgetFeature { + public struct WindowState: Equatable { + var alphaValue: Double = 0 + var frame: CGRect = .zero + } + + public enum WindowCanBecomeKey: Equatable { + case sharedPanel + case chatPanel + } + + @ObservableState + public struct State: Equatable { + var focusingDocumentURL: URL? + public var colorScheme: ColorScheme = .light + + var toastPanel = ToastPanel.State() + + // MARK: Panels + + public var panelState = PanelFeature.State() + + // MARK: ChatPanel + + public var chatPanelState = ChatPanelFeature.State() + + // MARK: CircularWidget + + public struct CircularWidgetState: Equatable { + var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]() + var isProcessing: Bool = false + } + + public var circularWidgetState = CircularWidgetState() + var _internalCircularWidgetState: CircularWidgetFeature.State { + get { + .init( + isProcessingCounters: circularWidgetState.isProcessingCounters, + isProcessing: circularWidgetState.isProcessing, + isDisplayingContent: { + if chatPanelState.isPanelDisplayed { + return true + } + if panelState.sharedPanelState.isPanelDisplayed, + !panelState.sharedPanelState.isEmpty + { + return true + } + if panelState.suggestionPanelState.isPanelDisplayed, + panelState.suggestionPanelState.content != nil + { + return true + } + return false + }(), + isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty + && panelState.sharedPanelState.isEmpty, + isChatPanelDetached: chatPanelState.isDetached, + isChatOpen: chatPanelState.isPanelDisplayed + ) + } + set { + circularWidgetState = .init( + isProcessingCounters: newValue.isProcessingCounters, + isProcessing: newValue.isProcessing + ) + } + } + + public init() {} + } + + private enum CancelID { + case observeActiveApplicationChange + case observeCompletionPanelChange + case observeFullscreenChange + case observeWindowChange + case observeEditorChange + case observeUserDefaults + } + + public enum Action: Equatable { + case startup + case observeActiveApplicationChange + case observeFullscreenChange + case observeColorSchemeChange + + case updateActiveApplication + case updateColorScheme + + case updatePanelStateToMatch(WidgetLocation) + case updateFocusingDocumentURL + case setFocusingDocumentURL(to: URL?) + case updateKeyWindow(WindowCanBecomeKey) + + case toastPanel(ToastPanel.Action) + case panel(PanelFeature.Action) + case chatPanel(ChatPanelFeature.Action) + case circularWidget(CircularWidgetFeature.Action) + } + + var windowsController: WidgetWindowsController? { + suggestionWidgetControllerDependency.windowsController + } + + @Dependency(\.suggestionWidgetUserDefaultsObservers) var userDefaultsObservers + @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency + @Dependency(\.xcodeInspector) var xcodeInspector + @Dependency(\.mainQueue) var mainQueue + @Dependency(\.activateThisApp) var activateThisApp + @Dependency(\.activatePreviousActiveApp) var activatePreviousActiveApp + + public enum DebounceKey: Hashable { + case updateWindowOpacity + } + + public init() {} + + public var body: some ReducerOf { + Scope(state: \.toastPanel, action: \.toastPanel) { + ToastPanel() + } + + Scope(state: \._internalCircularWidgetState, action: \.circularWidget) { + CircularWidgetFeature() + } + + Reduce { state, action in + switch action { + case .circularWidget(.detachChatPanelToggleClicked): + return .run { send in + await send(.chatPanel(.toggleChatPanelDetachedButtonClicked)) + } + + case .circularWidget(.widgetClicked): + guard FeatureFlagNotifierImpl.shared.featureFlags.chat else { + return .none + } + + let wasDisplayingContent = state._internalCircularWidgetState.isDisplayingContent + if wasDisplayingContent { + state.panelState.sharedPanelState.isPanelDisplayed = false + state.panelState.suggestionPanelState.isPanelDisplayed = false + state.chatPanelState.isPanelDisplayed = false + } else { + state.panelState.sharedPanelState.isPanelDisplayed = true + state.panelState.suggestionPanelState.isPanelDisplayed = true + state.chatPanelState.isPanelDisplayed = true + } + + let isDisplayingContent = state._internalCircularWidgetState.isDisplayingContent + let hasChat = state.chatPanelState.chatTabGroup.selectedTabInfo != nil + let hasPromptToCode = state.panelState.sharedPanelState.content + .promptToCodeGroup.activePromptToCode != nil + + return .run { send in + if isDisplayingContent { + if hasPromptToCode { + await send(.updateKeyWindow(.sharedPanel)) + } else if hasChat { + await send(.updateKeyWindow(.chatPanel)) + } + await send(.chatPanel(.focusActiveChatTab)) + } + + if isDisplayingContent, !(await NSApplication.shared.isActive) { + activateThisApp() + } else if !isDisplayingContent { + activatePreviousActiveApp() + } + } + + default: return .none + } + } + + Scope(state: \.panelState, action: \.panel) { + PanelFeature() + } + + Scope(state: \.chatPanelState, action: \.chatPanel) { + ChatPanelFeature() + } + + Reduce { state, action in + switch action { + case .chatPanel(.presentChatPanel): + let isDetached = state.chatPanelState.isDetached + return .run { _ in + await windowsController?.updateWindowLocation( + animated: false, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) + if isDetached { + Task { @MainActor in + windowsController?.windows.chatPanelWindow.isWindowHidden = false + } + } + } + + case .chatPanel(.toggleChatPanelDetachedButtonClicked): + let isDetached = state.chatPanelState.isDetached + return .run { _ in + await windowsController?.updateWindowLocation( + animated: !isDetached, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) + } + default: return .none + } + } + + Reduce { state, action in + switch action { + case .startup: + return .merge( + .run { send in + await send(.toastPanel(.start)) + await send(.observeActiveApplicationChange) + await send(.observeFullscreenChange) + await send(.observeColorSchemeChange) + } + ) + + case .observeActiveApplicationChange: + return .run { send in + let stream = AsyncStream { continuation in + let cancellable = xcodeInspector.$activeApplication.sink { newValue in + guard let newValue else { return } + continuation.yield(newValue) + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + + var previousAppIdentifier: pid_t? + for await app in stream { + try Task.checkCancellation() + if app.processIdentifier != previousAppIdentifier { + await send(.updateActiveApplication) + } + previousAppIdentifier = app.processIdentifier + } + }.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true) + + case .observeFullscreenChange: + return .run { _ in + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) + for await _ in sequence { + try Task.checkCancellation() + guard let activeXcode = await xcodeInspector.safe.activeXcode + else { continue } + guard let windowsController, + await windowsController.windows.fullscreenDetector.isOnActiveSpace + else { continue } + let app = activeXcode.appElement + if let _ = app.focusedWindow { + await windowsController.windows.orderFront() + } + } + }.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true) + + case .observeColorSchemeChange: + return .run { send in + await send(.updateColorScheme) + let stream = AsyncStream { continuation in + userDefaultsObservers.colorSchemeChangeObserver.onChange = { + continuation.yield() + } + + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = { + continuation.yield() + } + + continuation.onTermination = { _ in + userDefaultsObservers.colorSchemeChangeObserver.onChange = {} + userDefaultsObservers.systemColorSchemeChangeObserver.onChange = {} + } + } + + for await _ in stream { + try Task.checkCancellation() + await send(.updateColorScheme) + } + }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) + + case .updateActiveApplication: + return .none + + case .updateColorScheme: + let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) + let systemColorScheme: ColorScheme = NSApp.effectiveAppearance.name == .darkAqua + ? .dark + : .light + + let scheme: ColorScheme = { + switch (widgetColorScheme, systemColorScheme) { + case (.system, .dark), (.dark, _): + return .dark + case (.system, .light), (.light, _): + return .light + case (.system, _): + return .light + } + }() + + state.colorScheme = scheme + state.toastPanel.colorScheme = scheme + state.panelState.sharedPanelState.colorScheme = scheme + state.panelState.suggestionPanelState.colorScheme = scheme + state.chatPanelState.colorScheme = scheme + return .none + + case .updateFocusingDocumentURL: + return .run { send in + await send(.setFocusingDocumentURL( + to: await xcodeInspector.safe + .realtimeActiveDocumentURL + )) + } + + case let .setFocusingDocumentURL(url): + state.focusingDocumentURL = url + return .none + + case let .updatePanelStateToMatch(widgetLocation): + state.panelState.sharedPanelState.alignTopToAnchor = widgetLocation + .defaultPanelLocation + .alignPanelTop + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + state.panelState.suggestionPanelState.isPanelOutOfFrame = false + state.panelState.suggestionPanelState + .alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + state.panelState.suggestionPanelState.firstLineIndent = suggestionPanelLocation.firstLineIndent ?? 0 + if let lineHeight = suggestionPanelLocation.lineHeight { + state.panelState.suggestionPanelState.lineHeight = lineHeight + } + } else { + state.panelState.suggestionPanelState.isPanelOutOfFrame = true + } + + state.toastPanel.alignTopToAnchor = widgetLocation + .defaultPanelLocation + .alignPanelTop + + return .none + + case let .updateKeyWindow(window): + return .run { _ in + await MainActor.run { + switch window { + case .chatPanel: + windowsController?.windows.chatPanelWindow + .makeKeyAndOrderFront(nil) + case .sharedPanel: + windowsController?.windows.sharedPanelWindow + .makeKeyAndOrderFront(nil) + } + } + } + + case .toastPanel: + return .none + + case .circularWidget: + return .none + + case .panel: + return .none + + case .chatPanel: + return .none + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift new file mode 100644 index 0000000..0e83df6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -0,0 +1,88 @@ +import ActiveApplicationMonitor +import AppKit +import ChatTab +import ComposableArchitecture +import Dependencies +import Foundation +import Preferences +import SwiftUI +import UserDefaultsObserver +import XcodeInspector + +public final class SuggestionWidgetControllerDependency { + public var suggestionWidgetDataSource: SuggestionWidgetDataSource? + public var onOpenChatClicked: () -> Void = {} + public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + var windowsController: WidgetWindowsController? + + public init() {} +} + +public final class WidgetUserDefaultsObservers { + let presentationModeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionPresentationMode.key, + ], context: nil + ) + let colorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().widgetColorScheme.key, + ], context: nil + ) + let systemColorSchemeChangeObserver = UserDefaultsObserver( + object: UserDefaults.standard, forKeyPaths: ["AppleInterfaceStyle"], context: nil + ) + + public init() {} +} + +struct SuggestionWidgetControllerDependencyKey: DependencyKey { + static let liveValue = SuggestionWidgetControllerDependency() +} + +struct UserDefaultsDependencyKey: DependencyKey { + static let liveValue = WidgetUserDefaultsObservers() +} + +struct XcodeInspectorKey: DependencyKey { + static let liveValue = XcodeInspector.shared +} + +struct ActiveApplicationMonitorKey: DependencyKey { + static let liveValue = ActiveApplicationMonitor.shared +} + +struct ChatTabBuilderCollectionKey: DependencyKey { + static let liveValue: () -> [ChatTabBuilderCollection] = { [] } +} + +public extension DependencyValues { + var suggestionWidgetControllerDependency: SuggestionWidgetControllerDependency { + get { self[SuggestionWidgetControllerDependencyKey.self] } + set { self[SuggestionWidgetControllerDependencyKey.self] = newValue } + } + + var suggestionWidgetUserDefaultsObservers: WidgetUserDefaultsObservers { + get { self[UserDefaultsDependencyKey.self] } + set { self[UserDefaultsDependencyKey.self] = newValue } + } + + var chatTabBuilderCollection: () -> [ChatTabBuilderCollection] { + get { self[ChatTabBuilderCollectionKey.self] } + set { self[ChatTabBuilderCollectionKey.self] = newValue } + } +} + +extension DependencyValues { + var xcodeInspector: XcodeInspector { + get { self[XcodeInspectorKey.self] } + set { self[XcodeInspectorKey.self] = newValue } + } + + var activeApplicationMonitor: ActiveApplicationMonitor { + get { self[ActiveApplicationMonitorKey.self] } + set { self[ActiveApplicationMonitorKey.self] = newValue } + } +} + diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift new file mode 100644 index 0000000..dd50233 --- /dev/null +++ b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation +import Perception +import SharedUIComponents +import SwiftUI +import XcodeInspector + +@Perceptible +public final class CodeSuggestionProvider: Equatable { + public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + + public var code: String = "" + public var language: String = "" + public var startLineIndex: Int = 0 + public var suggestionCount: Int = 0 + public var currentSuggestionIndex: Int = 0 + public var extraInformation: String = "" + + @PerceptionIgnored public var onSelectPreviousSuggestionTapped: () -> Void + @PerceptionIgnored public var onSelectNextSuggestionTapped: () -> Void + @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void + @PerceptionIgnored public var onAcceptSuggestionTapped: () -> Void + @PerceptionIgnored public var onDismissSuggestionTapped: () -> Void + + public init( + code: String = "", + language: String = "", + startLineIndex: Int = 0, + startCharacerIndex: Int = 0, + suggestionCount: Int = 0, + currentSuggestionIndex: Int = 0, + onSelectPreviousSuggestionTapped: @escaping () -> Void = {}, + onSelectNextSuggestionTapped: @escaping () -> Void = {}, + onRejectSuggestionTapped: @escaping () -> Void = {}, + onAcceptSuggestionTapped: @escaping () -> Void = {}, + onDismissSuggestionTapped: @escaping () -> Void = {} + ) { + self.code = code + self.language = language + self.startLineIndex = startLineIndex + self.suggestionCount = suggestionCount + self.currentSuggestionIndex = currentSuggestionIndex + self.onSelectPreviousSuggestionTapped = onSelectPreviousSuggestionTapped + self.onSelectNextSuggestionTapped = onSelectNextSuggestionTapped + self.onRejectSuggestionTapped = onRejectSuggestionTapped + self.onAcceptSuggestionTapped = onAcceptSuggestionTapped + self.onDismissSuggestionTapped = onDismissSuggestionTapped + } + + func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() } + func selectNextSuggestion() { onSelectNextSuggestionTapped() } + func rejectSuggestion() { onRejectSuggestionTapped() } + func acceptSuggestion() { onAcceptSuggestionTapped() } + func dismissSuggestion() { onDismissSuggestionTapped() } + + +} + diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift new file mode 100644 index 0000000..6fed9f1 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -0,0 +1,184 @@ +import ComposableArchitecture +import Preferences +import SwiftUI + +extension View { + @ViewBuilder + func animation( + featureFlag: KeyPath, + _ animation: Animation?, + value: V + ) -> some View { + let isOn = UserDefaults.shared.value(for: featureFlag) + if isOn { + self.animation(animation, value: value) + } else { + self + } + } +} + +struct SharedPanelView: View { + var store: StoreOf + + struct OverallState: Equatable { + var isPanelDisplayed: Bool + var opacity: Double + var colorScheme: ColorScheme + var alignTopToAnchor: Bool + } + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + if !store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + + DynamicContent(store: store) + + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .allowsHitTesting(store.isPanelDisplayed) + .frame(maxWidth: .infinity) + + if store.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + } + .preferredColorScheme(store.colorScheme) + .opacity(store.opacity) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelDisplayed + ) + .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) + } + } + + struct DynamicContent: View { + let store: StoreOf + + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + WithPerceptionTracking { + ZStack(alignment: .topLeading) { + if let errorMessage = store.content.error { + error(errorMessage) + } else if let _ = store.content.promptToCode { + promptToCode() + } else if let suggestionProvider = store.content.suggestion { + suggestion(suggestionProvider) + } + } + } + } + + @ViewBuilder + func error(_ error: String) -> some View { + ErrorPanel(description: error) { + store.send( + .errorMessageCloseButtonTapped, + animation: .easeInOut(duration: 0.2) + ) + } + } + + @ViewBuilder + func promptToCode() -> some View { + if let store = store.scope( + state: \.content.promptToCodeGroup.activePromptToCode, + action: \.promptToCodeGroup.activePromptToCode + ) { + PromptToCodePanel(store: store) + } + } + + @ViewBuilder + func suggestion(_ suggestion: CodeSuggestionProvider) -> some View { + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion, firstLineIndent: 0, lineHeight: 12, isPanelDisplayed: true) + } + } + } +} + +struct CommandButtonStyle: ButtonStyle { + var color: Color + var cornerRadius: Double = 4 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.vertical, 4) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + ) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + } + } +} + +// MARK: - Previews + +struct SharedPanelView_Error_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(store: .init( + initialState: .init( + content: .init(error: "This is an error\nerror"), + colorScheme: .light, + isPanelDisplayed: true + ), + reducer: { SharedPanelFeature() } + )) + .frame(width: 450, height: 200) + } +} + +struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(store: .init( + initialState: .init( + content: .init( + suggestion: .init( + code: """ + - (void)addSubview:(UIView *)view { + [self addSubview:view]; + } + """, + language: "objective-c", + startLineIndex: 8, + suggestionCount: 2, + currentSuggestionIndex: 0 + ) + ), + colorScheme: .dark, + isPanelDisplayed: true + ), + reducer: { SharedPanelFeature() } + )) + .frame(width: 450, height: 200) + .background { + HStack { + Color.red + Color.green + Color.blue + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift new file mode 100644 index 0000000..d8be66d --- /dev/null +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -0,0 +1,141 @@ +import AppKit +import MarkdownUI +import SharedUIComponents +import SwiftUI + +enum Style { + static let panelHeight: Double = 560 + static let panelWidth: Double = 454 + static let inlineSuggestionMaxHeight: Double = 400 + static let inlineSuggestionPadding: Double = 25 + static let widgetHeight: Double = 20 + static var widgetWidth: Double { widgetHeight } + static let widgetPadding: Double = 4 + static let chatWindowTitleBarHeight: Double = 24 + static let trafficLightButtonSize: Double = 12 +} + +extension Color { + static var contentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.1580096483, green: 0.1730263829, blue: 0.2026666105, alpha: 1) + } + return .white + })) + } + + static var userChatContentBackground: Color { + Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in + if appearance.isDarkMode { + return #colorLiteral(red: 0.2284317913, green: 0.2145925438, blue: 0.3214019983, alpha: 1) + } + return #colorLiteral(red: 0.9458052187, green: 0.9311983998, blue: 0.9906365955, alpha: 1) + })) + } +} + +extension NSAppearance { + var isDarkMode: Bool { + if bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { + return true + } else { + return false + } + } +} + +struct XcodeLikeFrame: View { + @Environment(\.colorScheme) var colorScheme + let content: Content + let cornerRadius: Double + + var body: some View { + content.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Material.bar) + ) + .overlay( + RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) + .stroke(Color.black.opacity(0.1), style: .init(lineWidth: 1)) + ) // Add an extra border just incase the background is not displayed. + .overlay( + RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + .padding(1) + ) + } +} + +extension View { + func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { + XcodeLikeFrame(content: self, cornerRadius: cornerRadius ?? 10) + } +} + +extension MarkdownUI.Theme { + static func custom(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.primary) + BackgroundColor(Color.clear) + FontSize(fontSize) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .padding(.top, 14) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .top) { + HStack(alignment: .center) { + Text(configuration.language ?? "code") + .foregroundStyle(.tertiary) + .font(.callout) + .padding(.leading, 8) + .lineLimit(1) + Spacer() + CopyButton { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(configuration.content, forType: .string) + } + } + } + .markdownMargin(top: 4, bottom: 16) + } + } + + static func functionCall(fontSize: Double) -> MarkdownUI.Theme { + .gitHub.text { + ForegroundColor(.secondary) + BackgroundColor(Color.clear) + FontSize(fontSize - 1) + } + .list { configuration in + configuration.label + .markdownMargin(top: 4, bottom: 4) + } + .paragraph { configuration in + configuration.label + .markdownMargin(top: 0, bottom: 4) + } + .codeBlock { configuration in + configuration.label + .relativeLineSpacing(.em(0.225)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + } + .padding(16) + .background(Color(nsColor: .textBackgroundColor).opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .markdownMargin(top: 4, bottom: 4) + } + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift new file mode 100644 index 0000000..db03730 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -0,0 +1,140 @@ +import Combine +import Perception +import SharedUIComponents +import SuggestionBasic +import SwiftUI +import XcodeInspector +import ChatService +import Foundation +import SuggestionBasic + +public final class ExpandableSuggestionService: ObservableObject { + public static let shared = ExpandableSuggestionService() + @Published public var isSuggestionExpanded: Bool = false + + private init() {} +} + +struct CodeBlockSuggestionPanel: View { + let suggestion: CodeSuggestionProvider + let firstLineIndent: Double + let lineHeight: Double + let isPanelDisplayed: Bool + @Environment(CursorPositionTracker.self) var cursorPositionTracker + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.suggestionCodeFont) var codeFont + /// <#Description#> + @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces + @AppStorage(\.syncSuggestionHighlightTheme) var syncHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + @AppStorage(\.currentLineBackgroundColorLight) var currentLineBackgroundColorLight + @AppStorage(\.currentLineBackgroundColorDark) var currentLineBackgroundColorDark + @AppStorage(\.codeFontLight) var codeFontLight + @AppStorage(\.codeFontDark) var codeFontDark + + @ObservedObject var object = ExpandableSuggestionService.shared + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + WithPerceptionTracking { + AsyncCodeBlock( + code: suggestion.code, + language: suggestion.language, + startLineIndex: suggestion.startLineIndex, + scenario: "suggestion", + firstLineIndent: firstLineIndent, + lineHeight: lineHeight, + font: { + if syncHighlightTheme { + return colorScheme == .light ? codeFontLight.value.nsFont : codeFontDark.value.nsFont + } + return codeFont.value.nsFont + }(), + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value? + .swiftUIColor + { + return color + } + } + return nil + }(), + proposedBackgroundColor: { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor + { + return color + } + } + return nil + }(), + currentLineBackgroundColor: { + if colorScheme == .light, + let color = currentLineBackgroundColorLight.value?.swiftUIColor { + return color + } else if let color = currentLineBackgroundColorDark.value?.swiftUIColor { + return color + } + return nil + }(), + dimmedCharacterCount: suggestion.startLineIndex + == cursorPositionTracker.cursorPosition.line + ? cursorPositionTracker.cursorPosition.character + : 0, + isExpanded: $object.isSuggestionExpanded, + isPanelDisplayed: isPanelDisplayed + ) + .frame(maxWidth: .infinity) + .padding(Style.inlineSuggestionPadding) + } + } + } + .background(Color.clear) + } + } + +// MARK: - Previews + +#Preview("Code Block Suggestion Panel") { + CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider( + code: """ + LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { + ForEach(0.. Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Text(description) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.white) + .padding() + .background(Color.red) + + // close button + Button(action: onCloseButtonTap) { + Image(systemName: "xmark") + .padding([.leading, .bottom], 16) + .padding([.top, .trailing], 8) + .foregroundColor(.white) + } + .buttonStyle(.plain) + } + .xcodeStyleFrame() + } +} diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift new file mode 100644 index 0000000..682d9c7 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -0,0 +1,580 @@ +import ComposableArchitecture +import MarkdownUI +import SharedUIComponents +import SuggestionBasic +import SwiftUI + +struct PromptToCodePanel: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + TopBar(store: store) + + Content(store: store) + .overlay(alignment: .bottom) { + ActionBar(store: store) + .padding(.bottom, 8) + } + + Divider() + + Toolbar(store: store) + } + .background(.ultraThickMaterial) + .xcodeStyleFrame() + } + } +} + +extension PromptToCodePanel { + struct TopBar: View { + let store: StoreOf + + var body: some View { + HStack { + SelectionRangeButton(store: store) + Spacer() + CopyCodeButton(store: store) + } + .padding(2) + } + + struct SelectionRangeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1)) + }) { + let attachedToFilename = store.filename + let isAttached = store.isAttachedToSelectionRange + let selectionRange = store.selectionRange + let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6) + HStack(spacing: 4) { + Image( + systemName: isAttached ? "link" : "character.cursor.ibeam" + ) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(.white) + .background( + color, + in: RoundedRectangle( + cornerRadius: 4, + style: .continuous + ) + ) + + if isAttached { + HStack(spacing: 4) { + Text(attachedToFilename) + .lineLimit(1) + .truncationMode(.middle) + if let range = selectionRange { + Text(range.description) + } + }.foregroundColor(.primary) + } else { + Text("current selection").foregroundColor(.secondary) + } + } + .padding(2) + .padding(.trailing, 4) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(color, lineWidth: 1) + } + .background { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(0.2)) + } + .padding(2) + } + .keyboardShortcut("j", modifiers: [.command]) + .buttonStyle(.plain) + } + } + } + + struct CopyCodeButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + if !store.code.isEmpty { + CopyButton { + store.send(.copyCodeButtonTapped) + } + } + } + } + } + } + + struct ActionBar: View { + let store: StoreOf + + var body: some View { + HStack { + StopRespondingButton(store: store) + ActionButtons(store: store) + } + } + + struct StopRespondingButton: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if store.isResponding { + Button(action: { + store.send(.stopRespondingButtonTapped) + }) { + HStack(spacing: 4) { + Image(systemName: "stop.fill") + Text("Stop") + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + } + + struct ActionButtons: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + let isResponding = store.isResponding + let isCodeEmpty = store.code.isEmpty + let isDescriptionEmpty = store.description.isEmpty + var isRespondingButCodeIsReady: Bool { + isResponding + && !isCodeEmpty + && !isDescriptionEmpty + } + if !isResponding || isRespondingButCodeIsReady { + HStack { + Toggle("Continuous Mode", isOn: $store.isContinuous) + .toggleStyle(.checkbox) + + Button(action: { + store.send(.cancelButtonTapped) + }) { + Text("Cancel") + } + .buttonStyle(CommandButtonStyle(color: .gray)) + .keyboardShortcut("w", modifiers: [.command]) + + if !isCodeEmpty { + Button(action: { + store.send(.acceptButtonTapped) + }) { + Text("Accept(โŒ˜ + โŽ)") + } + .buttonStyle(CommandButtonStyle(color: .accentColor)) + .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) + } + } + .padding(8) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + } + } + } + } + } + + struct Content: View { + let store: StoreOf + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme + @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight + @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark + @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight + @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark + + var codeForegroundColor: Color? { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeForegroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeForegroundColorDark.value?.swiftUIColor { + return color + } + } + return nil + } + + var codeBackgroundColor: Color { + if syncHighlightTheme { + if colorScheme == .light, + let color = codeBackgroundColorLight.value?.swiftUIColor + { + return color + } else if let color = codeBackgroundColorDark.value?.swiftUIColor { + return color + } + } + return Color.contentBackground + } + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) + ErrorMessage(store: store) + DescriptionContent(store: store, codeForegroundColor: codeForegroundColor) + CodeContent(store: store, codeForegroundColor: codeForegroundColor) + } + } + .background(codeBackgroundColor) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + + struct ErrorMessage: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if let errorMessage = store.error, !errorMessage.isEmpty { + Text(errorMessage) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Color.red, + in: RoundedRectangle(cornerRadius: 4, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.primary.opacity(0.2), lineWidth: 1) + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + } + + struct DescriptionContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + var body: some View { + WithPerceptionTracking { + if !store.description.isEmpty { + Markdown(store.description) + .textSelection(.enabled) + .markdownTheme(.gitHub.text { + BackgroundColor(Color.clear) + ForegroundColor(codeForegroundColor) + }) + .padding() + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + } + + struct CodeContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + @AppStorage(\.wrapCodeInPromptToCode) var wrapCode + + var body: some View { + WithPerceptionTracking { + if store.code.isEmpty { + Text( + store.isResponding + ? "Thinking..." + : "Enter your requirement to generate code." + ) + .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary) + .padding() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } else { + if wrapCode { + CodeBlockInContent( + store: store, + codeForegroundColor: codeForegroundColor + ) + } else { + ScrollView(.horizontal) { + CodeBlockInContent( + store: store, + codeForegroundColor: codeForegroundColor + ) + } + .modify { + if #available(macOS 13.0, *) { + $0.scrollIndicators(.hidden) + } else { + $0 + } + } + } + } + } + } + + struct CodeBlockInContent: View { + let store: StoreOf + let codeForegroundColor: Color? + + @Environment(\.colorScheme) var colorScheme + @AppStorage(\.promptToCodeCodeFont) var codeFont + @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces + + var body: some View { + WithPerceptionTracking { + let startLineIndex = store.selectionRange?.start.line ?? 0 + let firstLinePrecedingSpaceCount = store.selectionRange?.start + .character ?? 0 + CodeBlock( + code: store.code, + language: store.language.rawValue, + startLineIndex: startLineIndex, + scenario: "promptToCode", + colorScheme: colorScheme, + firstLinePrecedingSpaceCount: firstLinePrecedingSpaceCount, + font: codeFont.value.nsFont, + droppingLeadingSpaces: hideCommonPrecedingSpaces, + proposedForegroundColor: codeForegroundColor + ) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1, anchor: .center) + } + } + } + } + } + + struct Toolbar: View { + let store: StoreOf + @FocusState var focusField: PromptToCode.State.FocusField? + + struct RevertButtonState: Equatable { + var isResponding: Bool + var canRevert: Bool + } + + var body: some View { + HStack { + RevertButton(store: store) + + HStack(spacing: 0) { + InputField(store: store, focusField: $focusField) + SendButton(store: store) + } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + .background { + Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) { + EmptyView() + } + .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift]) + } + .background { + Button(action: { focusField = .textField }) { + EmptyView() + } + .keyboardShortcut("l", modifiers: [.command]) + } + } + .padding(8) + .background(.ultraThickMaterial) + } + + struct RevertButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.revertButtonTapped) + }) { + Group { + Image(systemName: "arrow.uturn.backward") + } + .padding(6) + .background { + Circle().fill(Color(nsColor: .controlBackgroundColor)) + } + .overlay { + Circle() + .stroke(Color(nsColor: .controlColor), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(store.isResponding || !store.canRevert) + } + } + } + + struct InputField: View { + @Perception.Bindable var store: StoreOf + var focusField: FocusState.Binding + + var body: some View { + WithPerceptionTracking { + AutoresizingCustomTextEditor( + text: $store.prompt, + font: .systemFont(ofSize: 14), + isEditable: !store.isResponding, + maxHeight: 400, + onSubmit: { store.send(.modifyCodeButtonTapped) } + ) + .opacity(store.isResponding ? 0.5 : 1) + .disabled(store.isResponding) + .focused(focusField, equals: PromptToCode.State.FocusField.textField) + .bind($store.focusedField, to: focusField) + } + .padding(8) + .fixedSize(horizontal: false, vertical: true) + } + } + + struct SendButton: View { + let store: StoreOf + var body: some View { + WithPerceptionTracking { + Button(action: { + store.send(.modifyCodeButtonTapped) + }) { + Image(systemName: "paperplane.fill") + .padding(8) + } + .buttonStyle(.plain) + .disabled(store.isResponding) + .keyboardShortcut(KeyEquivalent.return, modifiers: []) + } + } + } + } +} + +// MARK: - Previews + +#Preview("Default") { + PromptToCodePanel(store: .init(initialState: .init( + code: """ + ForEach(0.. + + var body: some View { + WithPerceptionTracking { + VStack(spacing: 4) { + if !store.alignTopToAnchor { + Spacer() + } + + 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) + } + } + + if store.alignTopToAnchor { + Spacer() + } + } + .colorScheme(store.colorScheme) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift new file mode 100644 index 0000000..2f2306d --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -0,0 +1,56 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +struct SuggestionPanelView: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + 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 + ) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isPanelOutOfFrame + ) + .frame( + maxWidth: .infinity, + maxHeight: Style.inlineSuggestionMaxHeight, + alignment: .top + ) + } + } + + struct Content: View { + let store: StoreOf + + var body: some View { + WithPerceptionTracking { + if let content = store.content { + CodeBlockSuggestionPanel( + suggestion: content, + firstLineIndent: store.firstLineIndent, + lineHeight: store.lineHeight, + isPanelDisplayed: store.isPanelDisplayed + ) + .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift new file mode 100644 index 0000000..1d80452 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -0,0 +1,94 @@ +import ActiveApplicationMonitor +import AppKit +import AsyncAlgorithms +import ChatTab +import Combine +import ComposableArchitecture +import Preferences +import SwiftUI +import UserDefaultsObserver +import XcodeInspector + +@MainActor +public final class SuggestionWidgetController: NSObject { + let store: StoreOf + let chatTabPool: ChatTabPool + let windowsController: WidgetWindowsController + private var cancellable = Set() + + public let dependency: SuggestionWidgetControllerDependency + + public init( + store: StoreOf, + chatTabPool: ChatTabPool, + dependency: SuggestionWidgetControllerDependency + ) { + self.dependency = dependency + self.store = store + self.chatTabPool = chatTabPool + windowsController = .init(store: store, chatTabPool: chatTabPool) + + super.init() + + if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } + + dependency.windowsController = windowsController + + store.send(.startup) + Task { + await windowsController.start() + } + } +} + +// MARK: - Handle Events + +public extension SuggestionWidgetController { + func suggestCode() { + store.send(.panel(.presentSuggestion)) + } + + func expandSuggestion() { + store.withState { state in + if state.panelState.content.suggestion != nil { + store.send(.panel(.expandSuggestion)) + } + } + } + + func discardSuggestion() { + store.withState { state in + if state.panelState.content.suggestion != nil { + store.send(.panel(.discardSuggestion)) + } + } + } + + #warning("TODO: Make a progress controller that doesn't use TCA.") + func markAsProcessing(_ isProcessing: Bool) { + store.withState { state in + if isProcessing, !state.circularWidgetState.isProcessing { + store.send(.circularWidget(.markIsProcessing)) + } else if !isProcessing, state.circularWidgetState.isProcessing { + store.send(.circularWidget(.endIsProcessing)) + } + } + } + + func presentError(_ errorDescription: String) { + store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil)))) + } + + func presentChatRoom() { + store.send(.chatPanel(.presentChatPanel(forceDetach: false))) + } + + func presentDetachedGlobalChat() { + store.send(.chatPanel(.presentChatPanel(forceDetach: true))) + } + + func closeChatRoom() { +// store.send(.chatPanel(.closeChatPanel)) + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift new file mode 100644 index 0000000..f7ad662 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift @@ -0,0 +1,24 @@ +import Foundation + +public protocol SuggestionWidgetDataSource { + func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? +} + +struct MockWidgetDataSource: SuggestionWidgetDataSource { + func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? { + return CodeSuggestionProvider( + code: """ + func test() { + let x = 1 + let y = 2 + let z = x + y + } + """, + language: "swift", + startLineIndex: 1, + suggestionCount: 3, + currentSuggestionIndex: 0 + ) + } +} + diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift new file mode 100644 index 0000000..3cb6298 --- /dev/null +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -0,0 +1,346 @@ +import AppKit +import Foundation + +public struct WidgetLocation: Equatable { + 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? +} + +enum UpdateLocationStrategy { + struct AlignToTextCursor { + func framesForWindows( + editorFrame: CGRect, + mainScreen: NSScreen, + activeScreen: NSScreen, + editor: AXUIElement, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), + preferredInsideEditorMinWidth: Double = UserDefaults.shared + .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) + ) -> WidgetLocation { + guard let selectedRange: AXValue = try? editor + .copyValue(key: kAXSelectedTextRangeAttribute), + let rect: AXValue = try? editor.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: selectedRange + ) + else { + return FixedToBottom().framesForWindows( + editorFrame: editorFrame, + mainScreen: mainScreen, + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget + ) + } + var frame: CGRect = .zero + let found = AXValueGetValue(rect, .cgRect, &frame) + guard found else { + return FixedToBottom().framesForWindows( + editorFrame: editorFrame, + mainScreen: mainScreen, + activeScreen: activeScreen, + hideCircularWidget: hideCircularWidget + ) + } + return HorizontalMovable().framesForWindows( + y: mainScreen.frame.height - frame.maxY, + alignPanelTopToAnchor: nil, + editorFrame: editorFrame, + mainScreen: mainScreen, + activeScreen: activeScreen, + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget + ) + } + } + + struct FixedToBottom { + func framesForWindows( + editorFrame: CGRect, + mainScreen: NSScreen, + activeScreen: NSScreen, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), + preferredInsideEditorMinWidth: Double = UserDefaults.shared + .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan), + editorFrameExpendedSize: CGSize = .zero + ) -> WidgetLocation { + return HorizontalMovable().framesForWindows( + y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + alignPanelTopToAnchor: false, + editorFrame: editorFrame, + mainScreen: mainScreen, + activeScreen: activeScreen, + preferredInsideEditorMinWidth: preferredInsideEditorMinWidth, + hideCircularWidget: hideCircularWidget, + editorFrameExpendedSize: editorFrameExpendedSize + ) + } + } + + struct HorizontalMovable { + func framesForWindows( + y: CGFloat, + alignPanelTopToAnchor fixedAlignment: Bool?, + editorFrame: CGRect, + mainScreen: NSScreen, + activeScreen: NSScreen, + preferredInsideEditorMinWidth: Double, + hideCircularWidget: Bool = UserDefaults.shared.value(for: \.hideCircularWidget), + editorFrameExpendedSize: CGSize = .zero + ) -> WidgetLocation { + let maxY = max( + y, + mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + 4 + activeScreen.frame.minY + ) + let y = min( + maxY, + activeScreen.frame.maxY - 4, + 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 + let putPanelToTheRight = { + if editorFrame.size.width >= preferredInsideEditorMinWidth { return false } + return activeScreen.frame.maxX > proposedPanelX + Style.panelWidth + }() + let alignPanelTopToAnchor = fixedAlignment ?? (y > activeScreen.frame.midY) + + 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, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + + return .init( + widgetFrame: widgetFrameOnTheRightSide, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) + } else { + var proposedAnchorFrameOnTheLeftSide = CGRect( + x: editorFrame.minX + Style.widgetPadding, + y: proposedAnchorFrameOnTheRightSide.origin.y, + 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 + let putAnchorToTheLeft = { + if editorFrame.size.width >= preferredInsideEditorMinWidth { + if editorFrame.maxX <= activeScreen.frame.maxX { + return false + } + } + 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, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + return .init( + widgetFrame: widgetFrameOnTheLeftSide, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + 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, + width: Style.widgetWidth, + height: Style.widgetHeight + ) + return .init( + widgetFrame: widgetFrameOnTheRightSide, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) + } + } + } + } + + struct NearbyTextCursor { + func framesForSuggestionWindow( + editorFrame: CGRect, + mainScreen: NSScreen, + activeScreen: NSScreen, + editor: AXUIElement, + completionPanel: AXUIElement? + ) -> 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 + } + + // Always place suggestion window at cursor position. + return .init( + frame: .init( + x: editorFrame.minX, + y: mainScreen.frame.height - selectionFrame.minY - Style.inlineSuggestionMaxHeight + Style.inlineSuggestionPadding, + width: editorFrame.width, + height: Style.inlineSuggestionMaxHeight + ), + alignPanelTop: true, + firstLineIndent: selectionFrame.maxX - editorFrame.minX - Style.inlineSuggestionPadding, + lineHeight: selectionFrame.height + ) + } + } + + /// 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( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: selectedRange + ) + else { + return nil + } + var selectionFrame: CGRect = .zero + let found = AXValueGetValue(rect, .cgRect, &selectionFrame) + 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( + 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." + ) + + if foundFirstLine, + let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), + let firstLineRect: AXValue = try? editor.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: firstLineSelectionRange + ) + { + var firstLineFrame: CGRect = .zero + let foundFirstLineFrame = AXValueGetValue(firstLineRect, .cgRect, &firstLineFrame) + if foundFirstLineFrame { + selectionFrame = firstLineFrame + } + } + + return selectionFrame + } +} + diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift new file mode 100644 index 0000000..04368ae --- /dev/null +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -0,0 +1,324 @@ +import ActiveApplicationMonitor +import ComposableArchitecture +import GitHubCopilotService +import Preferences +import SuggestionBasic +import SwiftUI + +struct WidgetView: View { + let store: StoreOf + @State var isHovering: Bool = false + var onOpenChatClicked: () -> Void = {} + var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } + + @AppStorage(\.hideCircularWidget) var hideCircularWidget + + var body: some View { + WithPerceptionTracking { + Circle() + .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) + .onTapGesture { + store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) + } + .overlay { + Group { + if !hideCircularWidget { + WidgetAnimatedCircle(store: store) + } + } + } + .onHover { yes in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = yes + } + }.contextMenu { + WidgetContextMenu(store: store) + } + .opacity({ + if !hideCircularWidget { return 1 } + return 0 + }()) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 0.2), + value: store.isProcessing + ) + } + } +} + +struct WidgetAnimatedCircle: View { + let store: StoreOf + @State var processingProgress: Double = 0 + + struct OverlayCircleState: Equatable { + var isProcessing: Bool + var isContentEmpty: Bool + } + + var body: some View { + WithPerceptionTracking { + let minimumLineWidth: Double = 3 + let lineWidth = (1 - processingProgress) * + (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth + let scale = max(processingProgress * 1, 0.0001) + ZStack { + Circle() + .stroke( + Color(nsColor: .darkGray), + style: .init(lineWidth: minimumLineWidth) + ) + .padding(minimumLineWidth / 2) + + // how do I stop the repeatForever animation without removing the view? + // I tried many solutions found on stackoverflow but non of them works. + Group { + if store.isProcessing { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !store.isContentEmpty || store.isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1) + .repeatForever(autoreverses: true), + value: processingProgress + ) + } else { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !store.isContentEmpty || store.isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1), + value: processingProgress + ) + } + } + .onChange(of: store.isProcessing) { _ in + refreshRing( + isProcessing: store.isProcessing, + isContentEmpty: store.isContentEmpty + ) + } + .onChange(of: store.isContentEmpty) { _ in + refreshRing( + isProcessing: store.isProcessing, + isContentEmpty: store.isContentEmpty + ) + } + } + } + } + + func refreshRing(isProcessing: Bool, isContentEmpty: Bool) { + if isProcessing { + processingProgress = 1 - processingProgress + } else { + processingProgress = isContentEmpty ? 0 : 1 + } + } +} + +struct WidgetContextMenu: View { + @AppStorage(\.useGlobalChat) var useGlobalChat + @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle + @AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally + @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList + @AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList + @AppStorage(\.customCommands) var customCommands + let store: StoreOf + + @Dependency(\.xcodeInspector) var xcodeInspector + + var body: some View { + WithPerceptionTracking { + Group { // Commands + if !store.isChatOpen && FeatureFlagNotifierImpl.shared.featureFlags.chat { + Button(action: { + store.send(.openChatButtonClicked) + }) { + Text("Open Chat") + } + } + + if FeatureFlagNotifierImpl.shared.featureFlags.chat { + customCommandMenu() + } + } + + Divider() + + Group { + enableSuggestionForProject + + disableSuggestionForLanguage + } + + Divider() + + Group { // Settings + if FeatureFlagNotifierImpl.shared.featureFlags.chat { + Button(action: { + store.send(.detachChatPanelToggleClicked) + }) { + Text("Detach Chat Panel") + if store.isChatPanelDetached { + Image(systemName: "checkmark") + } + } + } + + Button(action: { + realtimeSuggestionToggle.toggle() + }) { + Text("Realtime Suggestion") + if realtimeSuggestionToggle { + Image(systemName: "checkmark") + } + } + } + + Divider() + } + } + + func customCommandMenu() -> some View { + Menu("Custom Commands") { + ForEach(customCommands, id: \.name) { command in + Button(action: { + store.send(.runCustomCommandButtonClicked(command)) + }) { + Text(command.name) + } + } + } + } +} + +extension WidgetContextMenu { + @ViewBuilder + var enableSuggestionForProject: some View { + if let projectPath = xcodeInspector.activeProjectRootURL?.path, + disableSuggestionFeatureGlobally + { + let matchedPath = suggestionFeatureEnabledProjectList.first { path in + projectPath.hasPrefix(path) + } + Button(action: { + if matchedPath != nil { + suggestionFeatureEnabledProjectList + .removeAll { path in path == matchedPath } + } else { + suggestionFeatureEnabledProjectList.append(projectPath) + } + }) { + if matchedPath == nil { + Text("Add to Suggestion-Enabled Project List") + } else { + Text("Remove from Suggestion-Enabled Project List") + } + } + } + } + + @ViewBuilder + var disableSuggestionForLanguage: some View { + let fileURL = xcodeInspector.activeDocumentURL + let fileLanguage = fileURL.map(languageIdentifierFromFileURL) ?? .plaintext + let matched = suggestionFeatureDisabledLanguageList.first { rawValue in + fileLanguage.rawValue == rawValue + } + Button(action: { + if let matched { + suggestionFeatureDisabledLanguageList.removeAll { $0 == matched } + } else { + suggestionFeatureDisabledLanguageList.append(fileLanguage.rawValue) + } + }) { + if matched == nil { + Text("Disable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") + } else { + Text("Enable Suggestion for \"\(fileLanguage.rawValue.capitalized)\"") + } + } + } +} + +struct WidgetView_Preview: PreviewProvider { + static var previews: some View { + VStack { + WidgetView( + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidgetFeature() } + ), + isHovering: false + ) + + WidgetView( + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidgetFeature() } + ), + isHovering: true + ) + + WidgetView( + store: Store( + initialState: .init( + isProcessing: true, + isDisplayingContent: false, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidgetFeature() } + ), + isHovering: false + ) + + WidgetView( + store: Store( + initialState: .init( + isProcessing: false, + isDisplayingContent: true, + isContentEmpty: true, + isChatPanelDetached: false, + isChatOpen: false + ), + reducer: { CircularWidgetFeature() } + ), + isHovering: false + ) + } + .frame(width: 30) + .background(Color.black) + } +} + diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift new file mode 100644 index 0000000..b8a4d56 --- /dev/null +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -0,0 +1,786 @@ +import AppKit +import AsyncAlgorithms +import ChatTab +import Combine +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI +import XcodeInspector + +actor WidgetWindowsController: NSObject { + let userDefaultsObservers = WidgetUserDefaultsObservers() + var xcodeInspector: XcodeInspector { .shared } + + let windows: WidgetWindows + let store: StoreOf + let chatTabPool: ChatTabPool + + var currentApplicationProcessIdentifier: pid_t? + + var cancellable: Set = [] + var observeToAppTask: Task? + var observeToFocusedEditorTask: Task? + + var updateWindowOpacityTask: Task? + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) + + var updateWindowLocationTask: Task? + var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0) + + var beatingCompletionPanelTask: Task? + + deinit { + userDefaultsObservers.presentationModeChangeObserver.onChange = {} + observeToAppTask?.cancel() + observeToFocusedEditorTask?.cancel() + } + + init(store: StoreOf, chatTabPool: ChatTabPool) { + self.store = store + self.chatTabPool = chatTabPool + windows = .init(store: store, chatTabPool: chatTabPool) + super.init() + windows.controller = self + } + + @MainActor func send(_ action: WidgetFeature.Action) { + store.send(action) + } + + func start() { + cancellable.removeAll() + + xcodeInspector.$activeApplication.sink { [weak self] app in + guard let app else { return } + Task { [weak self] in await self?.activate(app) } + }.store(in: &cancellable) + + xcodeInspector.$focusedEditor.sink { [weak self] editor in + guard let editor else { return } + Task { [weak self] in await self?.observe(toEditor: editor) } + }.store(in: &cancellable) + + xcodeInspector.$completionPanel.sink { [weak self] newValue in + Task { [weak self] in + await self?.handleCompletionPanelChange(isDisplaying: newValue != nil) + } + }.store(in: &cancellable) + + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in + Task { [weak self] in + await self?.updateWindowLocation(animated: false, immediately: false) + await self?.send(.updateColorScheme) + } + } + } +} + +// MARK: - Observation + +private extension WidgetWindowsController { + func activate(_ app: AppInstanceInspector) { + Task { + if app.isXcode { + updateWindowLocation(animated: false, immediately: true) + updateWindowOpacity(immediately: false) + } else { + updateWindowOpacity(immediately: true) + updateWindowLocation(animated: false, immediately: false) + await hideSuggestionPanelWindow() + } + await adjustChatPanelWindowLevel() + } + guard currentApplicationProcessIdentifier != app.processIdentifier else { return } + currentApplicationProcessIdentifier = app.processIdentifier + observe(toApp: app) + } + + func observe(toApp app: AppInstanceInspector) { + guard let app = app as? XcodeAppInstanceInspector else { return } + let notifications = app.axNotifications + observeToAppTask?.cancel() + observeToAppTask = Task { + await windows.orderFront() + + for await notification in await notifications.notifications() { + try Task.checkCancellation() + + /// Hide the widgets before switching to another window/editor + /// so the transition looks better. + func hideWidgetForTransitions() async { + let newDocumentURL = await xcodeInspector.safe.realtimeActiveDocumentURL + let documentURL = await MainActor + .run { store.withState { $0.focusingDocumentURL } } + if documentURL != newDocumentURL { + await send(.panel(.removeDisplayedContent)) + await hidePanelWindows() + } + await send(.updateFocusingDocumentURL) + } + + func removeContent() async { + await send(.panel(.removeDisplayedContent)) + } + + func updateWidgetsAndNotifyChangeOfEditor(immediately: Bool) async { + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + func updateWidgets(immediately: Bool) async { + updateWindowLocation(animated: false, immediately: immediately) + updateWindowOpacity(immediately: immediately) + } + + switch notification.kind { + case .focusedWindowChanged, .focusedUIElementChanged: + await hideWidgetForTransitions() + await updateWidgetsAndNotifyChangeOfEditor(immediately: true) + case .applicationActivated: + await updateWidgetsAndNotifyChangeOfEditor(immediately: false) + case .mainWindowChanged: + await updateWidgetsAndNotifyChangeOfEditor(immediately: false) + case .moved, + .resized, + .windowMoved, + .windowResized, + .windowMiniaturized, + .windowDeminiaturized: + await updateWidgets(immediately: false) + case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged, + .applicationDeactivated: + continue + case .titleChanged: + continue + } + } + } + } + + func observe(toEditor editor: SourceEditor) { + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = Task { + let selectionRangeChange = await editor.axNotifications.notifications() + .filter { $0.kind == .selectedTextChanged } + let scroll = await editor.axNotifications.notifications() + .filter { $0.kind == .scrollPositionChanged } + + if #available(macOS 13.0, *) { + for await notification in merge( + scroll, + selectionRangeChange.debounce(for: Duration.milliseconds(0)) + ) { + guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } else { + for await notification in merge(selectionRangeChange, scroll) { + guard await xcodeInspector.safe.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } + } + } + + func handleCompletionPanelChange(isDisplaying: Bool) { + beatingCompletionPanelTask?.cancel() + beatingCompletionPanelTask = Task { + if !isDisplaying { + // so that the buttons on the suggestion panel could be + // clicked + // before the completion panel updates the location of the + // suggestion panel + try await Task.sleep(nanoseconds: 400_000_000) + } + + updateWindowLocation(animated: false, immediately: false) + updateWindowOpacity(immediately: false) + } + } +} + +// MARK: - Window Updating + +extension WidgetWindowsController { + @MainActor + func hidePanelWindows() { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + } + + @MainActor + func hideSuggestionPanelWindow() { + windows.suggestionPanelWindow.alphaValue = 0 + send(.panel(.hidePanel)) + } + + func generateWidgetLocation() -> WidgetLocation? { + if let application = xcodeInspector.latestActiveXcode?.appElement { + if let focusElement = xcodeInspector.focusedEditor?.element, + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + case .alignToTextCursor: + var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + } + } else if var window = application.focusedWindow, + var frame = application.focusedWindow?.rect, + !["menu bar", "menu bar item"].contains(window.description), + 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) + { + // fallback to use workspace window + guard let workspaceWindow = application.windows + .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + let rect = workspaceWindow.rect + else { + return WidgetLocation( + widgetFrame: .zero, + tabFrame: .zero, + defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) + ) + } + + window = workspaceWindow + frame = rect + } + + var expendedSize = CGSize.zero + if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + // extra padding to bottom so buttons won't be covered + frame.size.height -= 40 + } else { + // move a bit away from the window so buttons won't be covered + frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 + frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth + expendedSize.width = (Style.widgetPadding * 2 + Style.widgetWidth) / 2 + expendedSize.height += Style.widgetPadding + } + + return UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + preferredInsideEditorMinWidth: 9_999_999_999, // never + editorFrameExpendedSize: expendedSize + ) + } + } + return nil + } + + func updatePanelState(_ location: WidgetLocation) async { + await send(.updatePanelStateToMatch(location)) + } + + func updateWindowOpacity(immediately: Bool) { + let shouldDebounce = !immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 3) + lastUpdateWindowOpacityTime = Date() + updateWindowOpacityTask?.cancel() + + let task = Task { + if shouldDebounce { + try await Task.sleep(nanoseconds: 200_000_000) + } + try Task.checkCancellation() + let xcodeInspector = self.xcodeInspector + let activeApp = await xcodeInspector.safe.activeApplication + let latestActiveXcode = await xcodeInspector.safe.latestActiveXcode + let previousActiveApplication = xcodeInspector.previousActiveApplication + await MainActor.run { + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.isDetached + let hasChat = !state.chatPanelState.chatTabGroup.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)) + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + + if isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = !hasChat + } else { + windows.chatPanelWindow.isWindowHidden = noFocus + } + } else if let activeApp, activeApp.isExtensionService { + let noFocus = { + guard let xcode = latestActiveXcode else { return true } + if let window = xcode.appElement.focusedWindow, + window.role == "AXWindow" + { + return false + } + return true + }() + + let previousAppIsXcode = previousActiveApplication?.isXcode ?? false + + send(.panel(noFocus ? .hidePanel : .showPanel)) + windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = if noFocus { + 0 + } else if previousAppIsXcode { + 1 + } else { + 0 + } + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + if isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = !hasChat + } else { + windows.chatPanelWindow.isWindowHidden = noFocus && !windows + .chatPanelWindow.isKeyWindow + } + } else { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + windows.widgetWindow.alphaValue = 0 + windows.toastWindow.alphaValue = 0 + if !isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = true + } + } + } + } + + updateWindowOpacityTask = task + } + + func updateWindowLocation( + animated: Bool, + immediately: Bool, + function: StaticString = #function, + line: UInt = #line + ) { + @Sendable @MainActor + func update() async { + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.isDetached + guard let widgetLocation = await generateWidgetLocation() else { return } + await updatePanelState(widgetLocation) + + windows.widgetWindow.setFrame( + widgetLocation.widgetFrame, + display: false, + animate: animated + ) + windows.toastWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + windows.sharedPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + windows.suggestionPanelWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + } + + if isChatPanelDetached { + // don't update it! + } else { + windows.chatPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + } + + await adjustChatPanelWindowLevel() + } + + let now = Date() + let shouldThrottle = !immediately && + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 3) + + updateWindowLocationTask?.cancel() + let interval: TimeInterval = 0.05 + + if shouldThrottle { + let delay = max( + 0, + interval - now.timeIntervalSince(lastUpdateWindowLocationTime) + ) + + updateWindowLocationTask = Task { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + try Task.checkCancellation() + await update() + } + } else { + Task { + await update() + } + } + lastUpdateWindowLocationTime = Date() + } + + @MainActor + func adjustChatPanelWindowLevel() async { + let disableFloatOnTopWhenTheChatPanelIsDetached = UserDefaults.shared + .value(for: \.disableFloatOnTopWhenTheChatPanelIsDetached) + + let window = windows.chatPanelWindow + guard disableFloatOnTopWhenTheChatPanelIsDetached else { + window.setFloatOnTop(true) + return + } + + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.isDetached + + guard isChatPanelDetached else { + window.setFloatOnTop(true) + return + } + + let floatOnTopWhenOverlapsXcode = UserDefaults.shared + .value(for: \.keepFloatOnTopIfChatPanelAndXcodeOverlaps) + + let latestApp = await xcodeInspector.safe.activeApplication + let latestAppIsXcodeOrExtension = if let latestApp { + latestApp.isXcode || latestApp.isExtensionService + } else { + false + } + + if !floatOnTopWhenOverlapsXcode || !latestAppIsXcodeOrExtension { + window.setFloatOnTop(false) + } else { + guard let xcode = await xcodeInspector.safe.latestActiveXcode else { return } + let windowElements = xcode.appElement.windows + let overlap = windowElements.contains { + if let position = $0.position, let size = $0.size { + let rect = CGRect( + x: position.x, + y: position.y, + width: size.width, + height: size.height + ) + return rect.intersects(window.frame) + } + return false + } + + window.setFloatOnTop(overlap) + } + } +} + +// MARK: - NSWindowDelegate + +extension WidgetWindowsController: NSWindowDelegate { + nonisolated + func windowWillMove(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.detachChatPanel)) + } + } + + nonisolated + func windowDidMove(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + await adjustChatPanelWindowLevel() + } + } + + nonisolated + func windowWillEnterFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.enterFullScreen)) + } + } + + nonisolated + func windowWillExitFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.exitFullScreen)) + } + } +} + +// MARK: - Windows + +public final class WidgetWindows { + let store: StoreOf + let chatTabPool: ChatTabPool + weak var controller: WidgetWindowsController? + let cursorPositionTracker = CursorPositionTracker() + + // you should make these window `.transient` so they never show up in the mission control. + + @MainActor + lazy var fullscreenDetector = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var widgetWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .floating + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: WidgetView( + store: store.scope( + state: \._internalCircularWidgetState, + action: \.circularWidget + ) + ) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var sharedPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: SharedPanelView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.sharedPanelState, + action: \.sharedPanel + ) + ).environment(cursorPositionTracker) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { [store] in + store.withState { state in + state.panelState.sharedPanelState.content.promptToCode != nil + } + } + return it + }() + + @MainActor + lazy var suggestionPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: SuggestionPanelView( + store: store.scope( + state: \.panelState, + action: \.panel + ).scope( + state: \.suggestionPanelState, + action: \.suggestionPanel + ) + ).environment(cursorPositionTracker) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var chatPanelWindow = { + let it = ChatPanelWindow( + store: store.scope( + state: \.chatPanelState, + action: \.chatPanel + ), + chatTabPool: chatTabPool, + minimizeWindow: { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } + ) + it.delegate = controller + it.isWindowHidden = true + return it + }() + + @MainActor + lazy var toastWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = true + it.backgroundColor = .clear + it.level = .floating + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: ToastPanelView(store: store.scope( + state: \.toastPanel, + action: \.toastPanel + )) + ) + it.setIsVisible(true) + it.ignoresMouseEvents = true + it.canBecomeKeyChecker = { false } + return it + }() + + init( + store: StoreOf, + chatTabPool: ChatTabPool + ) { + self.store = store + self.chatTabPool = chatTabPool + } + + @MainActor + func orderFront() { + widgetWindow.orderFrontRegardless() + toastWindow.orderFrontRegardless() + sharedPanelWindow.orderFrontRegardless() + suggestionPanelWindow.orderFrontRegardless() + if chatPanelWindow.level.rawValue > NSWindow.Level.normal.rawValue { + chatPanelWindow.orderFrontRegardless() + } + } +} + +// MARK: - Window Subclasses + +class CanBecomeKeyWindow: NSWindow { + var canBecomeKeyChecker: () -> Bool = { true } + override var canBecomeKey: Bool { canBecomeKeyChecker() } + override var canBecomeMain: Bool { canBecomeKeyChecker() } +} + diff --git a/Core/Sources/UpdateChecker/UpdateChecker.swift b/Core/Sources/UpdateChecker/UpdateChecker.swift new file mode 100644 index 0000000..d280bdd --- /dev/null +++ b/Core/Sources/UpdateChecker/UpdateChecker.swift @@ -0,0 +1,49 @@ +import Logger +import Preferences +import Sparkle + +public final class UpdateChecker { + let updater: SPUUpdater + let hostBundleFound: Bool + let delegate = UpdaterDelegate() + + public init(hostBundle: Bundle?) { + if hostBundle == nil { + hostBundleFound = false + Logger.updateChecker.error("Host bundle not found") + } else { + hostBundleFound = true + } + updater = SPUUpdater( + hostBundle: hostBundle ?? Bundle.main, + applicationBundle: Bundle.main, + userDriver: SPUStandardUserDriver(hostBundle: hostBundle ?? Bundle.main, delegate: nil), + delegate: delegate + ) + do { + try updater.start() + } catch { + Logger.updateChecker.error(error.localizedDescription) + } + } + + public func checkForUpdates() { + updater.checkForUpdates() + } + + public var automaticallyChecksForUpdates: Bool { + get { updater.automaticallyChecksForUpdates } + set { updater.automaticallyChecksForUpdates = newValue } + } +} + +class UpdaterDelegate: NSObject, SPUUpdaterDelegate { + func allowedChannels(for updater: SPUUpdater) -> Set { + if UserDefaults.shared.value(for: \.installPrereleases) { + Set(["prerelease"]) + } else { + [] + } + } +} + diff --git a/Core/Sources/UserDefaultsObserver/UserDefaultsObserver.swift b/Core/Sources/UserDefaultsObserver/UserDefaultsObserver.swift new file mode 100644 index 0000000..62ecce3 --- /dev/null +++ b/Core/Sources/UserDefaultsObserver/UserDefaultsObserver.swift @@ -0,0 +1,36 @@ +import Foundation + +public final class UserDefaultsObserver: NSObject { + public var onChange: (() -> Void)? + private weak var object: NSObject? + private let keyPaths: [String] + + public init( + object: NSObject, + forKeyPaths keyPaths: [String], + context: UnsafeMutableRawPointer? + ) { + self.object = object + self.keyPaths = keyPaths + super.init() + for keyPath in keyPaths { + object.addObserver(self, forKeyPath: keyPath, options: .new, context: context) + } + } + + deinit { + for keyPath in keyPaths { + object?.removeObserver(self, forKeyPath: keyPath) + } + } + + public override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + onChange?() + } +} + diff --git a/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift new file mode 100644 index 0000000..40e14b6 --- /dev/null +++ b/Core/Sources/XcodeThemeController/HighlightJSThemeTemplate.swift @@ -0,0 +1,107 @@ +import Foundation + +func buildHighlightJSTheme(_ theme: XcodeTheme) -> String { + /// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1` + + return """ + .hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + background: \(theme.backgroundColor.hexString); + color: \(theme.plainTextColor.hexString); + } + .xml .hljs-meta { + color: \(theme.marksColor.hexString); + } + .hljs-comment, + .hljs-quote { + color: \(theme.commentColor.hexString); + } + .hljs-tag, + .hljs-keyword, + .hljs-selector-tag, + .hljs-literal, + .hljs-name { + color: \(theme.keywordsColor.hexString); + } + .hljs-attribute { + color: \(theme.attributesColor.hexString); + } + .hljs-variable, + .hljs-template-variable { + color: \(theme.otherPropertiesAndGlobalsColor.hexString); + } + .hljs-code, + .hljs-string, + .hljs-meta-string { + color: \(theme.stringsColor.hexString); + } + .hljs-regexp { + color: \(theme.regexLiteralsColor.hexString); + } + .hljs-link { + color: \(theme.urlsColor.hexString); + } + .hljs-title { + color: \(theme.headingColor.hexString); + } + .hljs-symbol, + .hljs-bullet { + color: \(theme.attributesColor.hexString); + } + .hljs-number { + color: \(theme.numbersColor.hexString); + } + .hljs-section { + color: \(theme.marksColor.hexString); + } + .hljs-meta { + color: \(theme.keywordsColor.hexString); + } + .hljs-type, + .hljs-built_in, + .hljs-builtin-name { + color: \(theme.otherTypeNamesColor.hexString); + } + .hljs-class .hljs-title, + .hljs-title .class_ { + color: \(theme.typeDeclarationsColor.hexString); + } + .hljs-function .hljs-title, + .hljs-title .function_ { + color: \(theme.otherDeclarationsColor.hexString); + } + .hljs-params { + color: \(theme.otherDeclarationsColor.hexString); + } + .hljs-attr { + color: \(theme.attributesColor.hexString); + } + .hljs-subst { + color: \(theme.plainTextColor.hexString); + } + .hljs-formula { + background-color: \(theme.selectionColor.hexString); + font-style: italic; + } + .hljs-addition { + background-color: #baeeba; + } + .hljs-deletion { + background-color: #ffc8bd; + } + .hljs-selector-id, + .hljs-selector-class { + color: \(theme.plainTextColor.hexString); + } + .hljs-doctag, + .hljs-strong { + font-weight: bold; + } + .hljs-emphasis { + font-style: italic; + } + """ +} + diff --git a/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift new file mode 100644 index 0000000..f5536e3 --- /dev/null +++ b/Core/Sources/XcodeThemeController/HighlightrThemeManager.swift @@ -0,0 +1,89 @@ +import Foundation +import Highlightr +import Preferences + +public class HighlightrThemeManager: ThemeManager { + let defaultManager: ThemeManager + + weak var controller: XcodeThemeController? + + public init(defaultManager: ThemeManager, controller: XcodeThemeController) { + self.defaultManager = defaultManager + self.controller = controller + } + + public func theme(for name: String) -> Theme? { + let syncSuggestionTheme = UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme) + let syncPromptToCodeTheme = UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme) + let syncChatTheme = UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme) + + lazy var defaultLight = Theme(themeString: defaultLightTheme) + lazy var defaultDark = Theme(themeString: defaultDarkTheme) + + switch name { + case "suggestion-light": + guard syncSuggestionTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "suggestion-dark": + guard syncSuggestionTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "promptToCode-light": + guard syncPromptToCodeTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "promptToCode-dark": + guard syncPromptToCodeTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "chat-light": + guard syncChatTheme, let theme = theme(lightMode: true) else { + return defaultLight + } + return theme + case "chat-dark": + guard syncChatTheme, let theme = theme(lightMode: false) else { + return defaultDark + } + return theme + case "light": + return defaultLight + case "dark": + return defaultDark + default: + return defaultLight + } + } + + func theme(lightMode: Bool) -> Theme? { + guard let controller else { return nil } + guard let directories = controller.createSupportDirectoriesIfNeeded() else { return nil } + + let themeURL: URL = if lightMode { + directories.themeDirectory.appendingPathComponent("highlightjs-light") + } else { + directories.themeDirectory.appendingPathComponent("highlightjs-dark") + } + + if let themeString = try? String(contentsOf: themeURL) { + return Theme(themeString: themeString) + } + + controller.syncXcodeThemeIfNeeded() + + if let themeString = try? String(contentsOf: themeURL) { + return Theme(themeString: themeString) + } + + return nil + } +} + +let defaultLightTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#FFFFFFFF;color:#000000D8}.xml .hljs-meta{color:#495460FF}.hljs-comment,.hljs-quote{color:#5D6B79FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#9A2393FF}.hljs-attribute{color:#805E03FF}.hljs-variable,.hljs-template-variable{color:#6B36A9FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#C31A15FF}.hljs-regexp{color:#000000D8}.hljs-link{color:#0E0EFFFF}.hljs-title{color:#000000FF}.hljs-symbol,.hljs-bullet{color:#805E03FF}.hljs-number{color:#1C00CFFF}.hljs-section{color:#495460FF}.hljs-meta{color:#9A2393FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#3900A0FF}.hljs-class .hljs-title,.hljs-title .class_{color:#0B4F79FF}.hljs-function .hljs-title,.hljs-title .function_{color:#0E67A0FF}.hljs-params{color:#0E67A0FF}.hljs-attr{color:#805E03FF}.hljs-subst{color:#000000D8}.hljs-formula{background-color:#A3CCFEFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#000000D8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}" + +let defaultDarkTheme = ".hljs{display:block;overflow-x:auto;padding:0.5em;background:#1F1F23FF;color:#FFFFFFD8}.xml .hljs-meta{color:#91A1B1FF}.hljs-comment,.hljs-quote{color:#6B7985FF}.hljs-tag,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#FC5FA2FF}.hljs-attribute{color:#BF8554FF}.hljs-variable,.hljs-template-variable{color:#A166E5FF}.hljs-code,.hljs-string,.hljs-meta-string{color:#FC695DFF}.hljs-regexp{color:#FFFFFFD8}.hljs-link{color:#5482FEFF}.hljs-title{color:#FFFFFFFF}.hljs-symbol,.hljs-bullet{color:#BF8554FF}.hljs-number{color:#CFBF69FF}.hljs-section{color:#91A1B1FF}.hljs-meta{color:#FC5FA2FF}.hljs-type,.hljs-built_in,.hljs-builtin-name{color:#D0A7FEFF}.hljs-class .hljs-title,.hljs-title .class_{color:#5CD7FEFF}.hljs-function .hljs-title,.hljs-title .function_{color:#41A1BFFF}.hljs-params{color:#41A1BFFF}.hljs-attr{color:#BF8554FF}.hljs-subst{color:#FFFFFFD8}.hljs-formula{background-color:#505A6FFF;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#FFFFFFD8}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}" diff --git a/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift new file mode 100644 index 0000000..0d46af1 --- /dev/null +++ b/Core/Sources/XcodeThemeController/PreferenceKey+Theme.swift @@ -0,0 +1,27 @@ +import Foundation +import Preferences + +// MARK: - Theming + +public extension UserDefaultPreferenceKeys { + var lightXcodeThemeName: PreferenceKey { + .init(defaultValue: "", key: "LightXcodeThemeName") + } + + var lightXcodeTheme: PreferenceKey> { + .init(defaultValue: .init(nil), key: "LightXcodeTheme") + } + + var darkXcodeThemeName: PreferenceKey { + .init(defaultValue: "", key: "DarkXcodeThemeName") + } + + var darkXcodeTheme: PreferenceKey> { + .init(defaultValue: .init(nil), key: "LightXcodeTheme") + } + + var lastSyncedHighlightJSThemeCreatedAt: PreferenceKey { + .init(defaultValue: 0, key: "LastSyncedHighlightJSThemeCreatedAt") + } +} + diff --git a/Core/Sources/XcodeThemeController/XcodeThemeController.swift b/Core/Sources/XcodeThemeController/XcodeThemeController.swift new file mode 100644 index 0000000..f27cfe3 --- /dev/null +++ b/Core/Sources/XcodeThemeController/XcodeThemeController.swift @@ -0,0 +1,264 @@ +import AppKit +import Foundation +import Highlightr +import XcodeInspector + +public class XcodeThemeController { + var syncTriggerTask: Task? + + public init(syncTriggerTask: Task? = nil) { + self.syncTriggerTask = syncTriggerTask + } + + public func start() { + let defaultHighlightrThemeManager = Highlightr.themeManager + Highlightr.themeManager = HighlightrThemeManager( + defaultManager: defaultHighlightrThemeManager, + controller: self + ) + + syncXcodeThemeIfNeeded() + + syncTriggerTask?.cancel() + syncTriggerTask = Task { [weak self] in + let notifications = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in notifications { + try Task.checkCancellation() + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + guard app.isCopilotForXcodeExtensionService || app.isXcode else { continue } + guard let self else { return } + self.syncXcodeThemeIfNeeded() + } + } + } +} + +extension XcodeThemeController { + func syncXcodeThemeIfNeeded() { + guard UserDefaults.shared.value(for: \.syncSuggestionHighlightTheme) + || UserDefaults.shared.value(for: \.syncPromptToCodeHighlightTheme) + || UserDefaults.shared.value(for: \.syncChatCodeHighlightTheme) + else { return } + guard let directories = createSupportDirectoriesIfNeeded() else { return } + + defer { + UserDefaults.shared.set( + Date().timeIntervalSince1970, + for: \.lastSyncedHighlightJSThemeCreatedAt + ) + } + + let xcodeUserDefaults = UserDefaults(suiteName: "com.apple.dt.Xcode")! + + if let darkThemeName = xcodeUserDefaults + .value(forKey: "XCFontAndColorCurrentDarkTheme") as? String + { + syncXcodeThemeIfNeeded( + xcodeThemeName: darkThemeName, + light: false, + in: directories.themeDirectory + ) + } + + if let lightThemeName = xcodeUserDefaults + .value(forKey: "XCFontAndColorCurrentTheme") as? String + { + syncXcodeThemeIfNeeded( + xcodeThemeName: lightThemeName, + light: true, + in: directories.themeDirectory + ) + } + } + + func syncXcodeThemeIfNeeded( + xcodeThemeName: String, + light: Bool, + in directoryURL: URL + ) { + let targetName = light ? "highlightjs-light" : "highlightjs-dark" + guard let xcodeThemeURL = locateXcodeTheme(named: xcodeThemeName) else { return } + let targetThemeURL = directoryURL.appendingPathComponent(targetName) + let lastSyncTimestamp = UserDefaults.shared + .value(for: \.lastSyncedHighlightJSThemeCreatedAt) + + let shouldSync = { + if light, UserDefaults.shared.value(for: \.lightXcodeTheme) == nil { return true } + if !light, UserDefaults.shared.value(for: \.darkXcodeTheme) == nil { return true } + if light, xcodeThemeName != UserDefaults.shared.value(for: \.lightXcodeThemeName) { + return true + } + if !light, xcodeThemeName != UserDefaults.shared.value(for: \.darkXcodeThemeName) { + return true + } + if !FileManager.default.fileExists(atPath: targetThemeURL.path) { return true } + + let xcodeThemeFileUpdated = { + guard let xcodeThemeModifiedDate = try? xcodeThemeURL + .resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + else { return true } + return xcodeThemeModifiedDate.timeIntervalSince1970 > lastSyncTimestamp + }() + + if xcodeThemeFileUpdated { return true } + + return false + }() + + if shouldSync { + do { + let theme = try XcodeTheme(fileURL: xcodeThemeURL) + let highlightrTheme = theme.asHighlightJSTheme() + try highlightrTheme.write(to: targetThemeURL, atomically: true, encoding: .utf8) + + Task { @MainActor in + if light { + UserDefaults.shared.set(xcodeThemeName, for: \.lightXcodeThemeName) + UserDefaults.shared.set(.init(theme), for: \.lightXcodeTheme) + UserDefaults.shared.set( + .init(theme.plainTextColor.storable), + for: \.codeForegroundColorLight + ) + UserDefaults.shared.set( + .init(theme.backgroundColor.storable), + for: \.codeBackgroundColorLight + ) + UserDefaults.shared.set( + .init(theme.plainTextFont.storable), + for: \.codeFontLight + ) + UserDefaults.shared.set( + .init(theme.currentLineColor.storable), + for: \.currentLineBackgroundColorLight + ) + } else { + UserDefaults.shared.set(xcodeThemeName, for: \.darkXcodeThemeName) + UserDefaults.shared.set(.init(theme), for: \.darkXcodeTheme) + UserDefaults.shared.set( + .init(theme.plainTextColor.storable), + for: \.codeForegroundColorDark + ) + UserDefaults.shared.set( + .init(theme.backgroundColor.storable), + for: \.codeBackgroundColorDark + ) + UserDefaults.shared.set( + .init(theme.plainTextFont.storable), + for: \.codeFontDark + ) + UserDefaults.shared.set( + .init(theme.currentLineColor.storable), + for: \.currentLineBackgroundColorDark + ) + } + } + } catch { + print(error.localizedDescription) + } + } + } + + func locateXcodeTheme(named name: String) -> URL? { + if let customThemeURL = FileManager.default.urls( + for: .libraryDirectory, + in: .userDomainMask + ).first?.appendingPathComponent("Developer/Xcode/UserData/FontAndColorThemes") + .appendingPathComponent(name), + FileManager.default.fileExists(atPath: customThemeURL.path) + { + return customThemeURL + } + + let xcodeURL: URL? = { + // Use the latest running Xcode + if let running = XcodeInspector.shared.latestActiveXcode?.bundleURL { + return running + } + // Use the main Xcode.app + let proposedXcodeURL = URL(fileURLWithPath: "/Applications/Xcode.app") + if FileManager.default.fileExists(atPath: proposedXcodeURL.path) { + return proposedXcodeURL + } + // Look for an Xcode.app + if let applicationsURL = FileManager.default.urls( + for: .applicationDirectory, + in: .localDomainMask + ).first { + struct InfoPlist: Codable { + var CFBundleIdentifier: String + } + + let appBundleIdentifier = "com.apple.dt.Xcode" + let appDirectories = try? FileManager.default.contentsOfDirectory( + at: applicationsURL, + includingPropertiesForKeys: [], + options: .skipsHiddenFiles + ) + for appDirectoryURL in appDirectories ?? [] { + let infoPlistURL = appDirectoryURL.appendingPathComponent("Contents/Info.plist") + if let data = try? Data(contentsOf: infoPlistURL), + let infoPlist = try? PropertyListDecoder().decode( + InfoPlist.self, + from: data + ), + infoPlist.CFBundleIdentifier == appBundleIdentifier + { + return appDirectoryURL + } + } + } + return nil + }() + + if let url = xcodeURL? + .appendingPathComponent("Contents/SharedFrameworks/DVTUserInterfaceKit.framework") + .appendingPathComponent("Versions/A/Resources/FontAndColorThemes") + .appendingPathComponent(name), + FileManager.default.fileExists(atPath: url.path) + { + return url + } + + return nil + } + + func createSupportDirectoriesIfNeeded() -> (supportDirectory: URL, themeDirectory: URL)? { + guard let supportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent( + Bundle.main + .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String + ) else { + return nil + } + + let themeURL = supportURL.appendingPathComponent("Themes") + + do { + if !FileManager.default.fileExists(atPath: supportURL.path) { + try FileManager.default.createDirectory( + at: supportURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + + if !FileManager.default.fileExists(atPath: themeURL.path) { + try FileManager.default.createDirectory( + at: themeURL, + withIntermediateDirectories: true, + attributes: nil + ) + } + } catch { + return nil + } + + return (supportURL, themeURL) + } +} + diff --git a/Core/Sources/XcodeThemeController/XcodeThemeParser.swift b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift new file mode 100644 index 0000000..cb4f0fb --- /dev/null +++ b/Core/Sources/XcodeThemeController/XcodeThemeParser.swift @@ -0,0 +1,358 @@ +import Foundation +import Preferences + +public struct XcodeTheme: Codable { + public struct ThemeColor: Codable { + public var red: Double + public var green: Double + public var blue: Double + public var alpha: Double + + public var hexString: String { + let red = Int(self.red * 255) + let green = Int(self.green * 255) + let blue = Int(self.blue * 255) + let alpha = Int(self.alpha * 255) + return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha) + } + + var storable: StorableColor { + .init(red: red, green: green, blue: blue, alpha: alpha) + } + } + + public struct ThemeFont: Codable { + public var name: String + public var size: Double + + var storable: StorableFont { + .init(name: name, size: size) + } + } + + public var plainTextColor: ThemeColor + public var plainTextFont: ThemeFont + public var commentColor: ThemeColor + public var documentationMarkupColor: ThemeColor + public var documentationMarkupKeywordColor: ThemeColor + public var marksColor: ThemeColor + public var stringsColor: ThemeColor + public var charactersColor: ThemeColor + public var numbersColor: ThemeColor + public var regexLiteralsColor: ThemeColor + public var regexLiteralNumbersColor: ThemeColor + public var regexLiteralCaptureNamesColor: ThemeColor + public var regexLiteralCharacterClassNamesColor: ThemeColor + public var regexLiteralOperatorsColor: ThemeColor + public var keywordsColor: ThemeColor + public var preprocessorStatementsColor: ThemeColor + public var urlsColor: ThemeColor + public var attributesColor: ThemeColor + public var typeDeclarationsColor: ThemeColor + public var otherDeclarationsColor: ThemeColor + public var projectClassNamesColor: ThemeColor + public var projectFunctionAndMethodNamesColor: ThemeColor + public var projectConstantsColor: ThemeColor + public var projectTypeNamesColor: ThemeColor + public var projectPropertiesAndGlobalsColor: ThemeColor + public var projectPreprocessorMacrosColor: ThemeColor + public var otherClassNamesColor: ThemeColor + public var otherFunctionAndMethodNamesColor: ThemeColor + public var otherConstantsColor: ThemeColor + public var otherTypeNamesColor: ThemeColor + public var otherPropertiesAndGlobalsColor: ThemeColor + public var otherPreprocessorMacrosColor: ThemeColor + public var headingColor: ThemeColor + public var backgroundColor: ThemeColor + public var selectionColor: ThemeColor + public var cursorColor: ThemeColor + public var currentLineColor: ThemeColor + public var invisibleCharactersColor: ThemeColor + public var debuggerConsolePromptColor: ThemeColor + public var debuggerConsoleOutputColor: ThemeColor + public var debuggerConsoleInputColor: ThemeColor + public var executableConsoleOutputColor: ThemeColor + public var executableConsoleInputColor: ThemeColor + + public func asHighlightJSTheme() -> String { + buildHighlightJSTheme(self) + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: ": ", with: ":") + .replacingOccurrences(of: "} ", with: "}") + .replacingOccurrences(of: " {", with: "{") + .replacingOccurrences(of: ";}", with: "}") + .replacingOccurrences(of: " ", with: "") + } +} + +public extension XcodeTheme { + /// Color scheme locations: + /// ~/Library/Developer/Xcode/UserData/FontAndColorThemes/ + /// Xcode.app/Contents/SharedFrameworks/DVTUserInterfaceKit.framework/Versions/A/Resources/FontAndColorThemes + init(fileURL: URL) throws { + let parser = XcodeThemeParser() + self = try parser.parse(fileURL: fileURL) + } +} + +struct XcodeThemeParser { + enum Error: Swift.Error { + case fileNotFound + case invalidData + } + + func parse(fileURL: URL) throws -> XcodeTheme { + guard let data = try? Data(contentsOf: fileURL) else { + throw Error.fileNotFound + } + + if fileURL.pathExtension == "xccolortheme" { + return try parseXCColorTheme(data) + } else { + throw Error.invalidData + } + } + + func parseXCColorTheme(_ data: Data) throws -> XcodeTheme { + let plist = try? PropertyListSerialization.propertyList( + from: data, + options: .mutableContainers, + format: nil + ) as? [String: Any] + + guard let theme = plist else { throw Error.invalidData } + + func getRawThemeValue(at path: [String]) -> String? { + guard !path.isEmpty else { return nil } + let keys = path.dropLast(1) + var currentDict = theme + for key in keys { + guard let value = currentDict[key] as? [String: Any] else { + return nil + } + currentDict = value + } + return currentDict[path.last!] as? String + } + + /// The source value is an `r g b a` string, for example: `0.5 0.5 0.2 1` + func convertColor(source: String) -> XcodeTheme.ThemeColor { + let components = source.split(separator: " ") + let red = (components[0] as NSString).doubleValue + let green = (components[1] as NSString).doubleValue + let blue = (components[2] as NSString).doubleValue + let alpha = (components[3] as NSString).doubleValue + return .init(red: red, green: green, blue: blue, alpha: alpha) + } + + func getThemeValue( + at path: [String], + defaultValue: XcodeTheme.ThemeColor = .init(red: 0, green: 0, blue: 0, alpha: 1) + ) -> XcodeTheme.ThemeColor { + if let value = getRawThemeValue(at: path) { + return convertColor(source: value) + } + return defaultValue + } + + /// The source value is an `FontName - size` string, for example: `SFMono-Medium - 12.0` + func convertFont(source: String) -> XcodeTheme.ThemeFont? { + if let separator = source.range(of: " - ") { + let name = String(source.prefix(upTo: separator.lowerBound)) + let size = Double(source.suffix(from: separator.upperBound)) ?? 0.0 + return .init(name: name, size: size) + } + return nil + } + + func getThemeFont( + at path: [String], + defaultValue: XcodeTheme.ThemeFont = .init(name: "SFMono-Medium", size: 12.0) + ) -> XcodeTheme.ThemeFont { + if let value = getRawThemeValue(at: path) { + return convertFont(source: value) ?? defaultValue + } + return defaultValue + } + + let black = XcodeTheme.ThemeColor(red: 0, green: 0, blue: 0, alpha: 1) + let white = XcodeTheme.ThemeColor(red: 1, green: 1, blue: 1, alpha: 1) + + let xcodeTheme = XcodeTheme( + plainTextColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + plainTextFont: getThemeFont( + at: ["DVTSourceTextSyntaxFonts", "xcode.syntax.plain"] + ), + commentColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment"], + defaultValue: black + ), + documentationMarkupColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc"], + defaultValue: black + ), + documentationMarkupKeywordColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.comment.doc.keyword"], + defaultValue: black + ), + marksColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.mark"], + defaultValue: black + ), + stringsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.string"], + defaultValue: black + ), + charactersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.character"], + defaultValue: black + ), + numbersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"], + defaultValue: black + ), + regexLiteralsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralNumbersColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.number"], + defaultValue: black + ), + regexLiteralCaptureNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralCharacterClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + regexLiteralOperatorsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.plain"], + defaultValue: black + ), + keywordsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.keyword"], + defaultValue: black + ), + preprocessorStatementsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.preprocessor"], + defaultValue: black + ), + urlsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.url"], + defaultValue: black + ), + attributesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.attribute"], + defaultValue: black + ), + typeDeclarationsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.type"], + defaultValue: black + ), + otherDeclarationsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.declaration.other"], + defaultValue: black + ), + projectClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class"], + defaultValue: black + ), + projectFunctionAndMethodNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function"], + defaultValue: black + ), + projectConstantsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant"], + defaultValue: black + ), + projectTypeNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type"], + defaultValue: black + ), + projectPropertiesAndGlobalsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable"], + defaultValue: black + ), + projectPreprocessorMacrosColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro"], + defaultValue: black + ), + otherClassNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.class.system"], + defaultValue: black + ), + otherFunctionAndMethodNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.function.system"], + defaultValue: black + ), + otherConstantsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.constant.system"], + defaultValue: black + ), + otherTypeNamesColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.type.system"], + defaultValue: black + ), + otherPropertiesAndGlobalsColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.variable.system"], + defaultValue: black + ), + otherPreprocessorMacrosColor: getThemeValue( + at: ["DVTSourceTextSyntaxColors", "xcode.syntax.identifier.macro.system"], + defaultValue: black + ), + headingColor: getThemeValue( + at: ["DVTMarkupTextPrimaryHeadingColor"], + defaultValue: black + ), + backgroundColor: getThemeValue( + at: ["DVTSourceTextBackground"], + defaultValue: white + ), + selectionColor: getThemeValue( + at: ["DVTSourceTextSelectionColor"], + defaultValue: black + ), + cursorColor: getThemeValue( + at: ["DVTSourceTextInsertionPointColor"], + defaultValue: black + ), + currentLineColor: getThemeValue( + at: ["DVTSourceTextCurrentLineHighlightColor"], + defaultValue: black + ), + invisibleCharactersColor: getThemeValue( + at: ["DVTSourceTextInvisiblesColor"], + defaultValue: black + ), + debuggerConsolePromptColor: getThemeValue( + at: ["DVTConsoleDebuggerPromptTextColor"], + defaultValue: black + ), + debuggerConsoleOutputColor: getThemeValue( + at: ["DVTConsoleDebuggerOutputTextColor"], + defaultValue: black + ), + debuggerConsoleInputColor: getThemeValue( + at: ["DVTConsoleDebuggerInputTextColor"], + defaultValue: black + ), + executableConsoleOutputColor: getThemeValue( + at: ["DVTConsoleExectuableOutputTextColor"], + defaultValue: black + ), + executableConsoleInputColor: getThemeValue( + at: ["DVTConsoleExectuableInputTextColor"], + defaultValue: black + ) + ) + + return xcodeTheme + } +} + diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift new file mode 100644 index 0000000..545e488 --- /dev/null +++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift @@ -0,0 +1,227 @@ +import Foundation +import XCTest + +@testable import Workspace +@testable import KeyBindingManager + +class TabToAcceptSuggestionTests: XCTestCase { + @WorkspaceActor + func test_should_accept() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertTrue( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_without_suggestion() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_without_editor_focused() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: false + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: CGEvent(keyboardEventSource: nil, virtualKey: 48, keyDown: true)!, + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_without_active_xcode() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: false, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_without_active_document() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: nil, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_with_shift() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48, flags: .maskShift), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_with_command() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48, flags: .maskCommand), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_with_control() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48, flags: .maskControl), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @WorkspaceActor + func test_should_not_accept_with_help() { + let fileURL = URL(string: "file:///test")! + let workspacePool = FakeWorkspacePool() + workspacePool.setTestFile(fileURL: fileURL) + let xcodeInspector = FakeThreadSafeAccessToXcodeInspector( + activeDocumentURL: fileURL, + hasActiveXcode: true, + hasFocusedEditor: true + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(48, flags: .maskHelp), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } + + @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 + ) + XCTAssertFalse( + TabToAcceptSuggestion.shouldAcceptSuggestion( + event: createEvent(50), + workspacePool: workspacePool, + xcodeInspector: xcodeInspector + ) + ) + } +} + +private func createEvent(_ keyCode: CGKeyCode, flags: CGEventFlags = []) -> CGEvent { + let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)! + event.flags = flags + return event +} + +private struct FakeThreadSafeAccessToXcodeInspector: ThreadSafeAccessToXcodeInspectorProtocol { + let activeDocumentURL: URL? + let hasActiveXcode: Bool + let hasFocusedEditor: Bool +} + +private class FakeWorkspacePool: WorkspacePool { + private var fileURL: URL? + private var filespace: Filespace? + + @WorkspaceActor + func setTestFile(fileURL: URL) { + self.fileURL = fileURL + self.filespace = Filespace(fileURL: fileURL, onSave: {_ in }, onClose: {_ in }) + guard let filespace = self.filespace else { return } + filespace.setSuggestions([.init(id: "id", text: "test", position: .zero, range: .zero)]) + } + + override func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + guard fileURL == self.fileURL else { return .none } + return filespace + } +} + diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift new file mode 100644 index 0000000..191bf6a --- /dev/null +++ b/Core/Tests/ServiceTests/Environment.swift @@ -0,0 +1,74 @@ +import AppKit +import Client +import Foundation +import GitHubCopilotService +import SuggestionBasic +import Workspace +import XCTest +import XPCShared + +@testable import Service + +func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSuggestion { + .init(id: uuid, text: text, position: range.start, range: range) +} + +class MockSuggestionService: GitHubCopilotSuggestionServiceType { + func terminate() async { + fatalError() + } + + func cancelRequest() async { + fatalError() + } + + func notifyOpenTextDocument(fileURL: URL, content: String) async throws { + fatalError() + } + + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws { + fatalError() + } + + func notifyCloseTextDocument(fileURL: URL) async throws { + fatalError() + } + + func notifySaveTextDocument(fileURL: URL) async throws { + fatalError() + } + + var completions = [CodeSuggestion]() + var shown: String? + var accepted: String? + var rejected: [String] = [] + + init(completions: [CodeSuggestion]) { + self.completions = completions + } + + func getCompletions( + fileURL: URL, + content: String, + originalContent: String, + cursorPosition: SuggestionBasic.CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) async throws -> [SuggestionBasic.CodeSuggestion] { + completions + } + + func notifyShown(_ completion: SuggestionBasic.CodeSuggestion) async { + shown = completion.id + } + + func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { + accepted = completion.id + } + + func notifyRejected(_ completions: [CodeSuggestion]) async { + rejected = completions.map(\.id) + } +} + diff --git a/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift new file mode 100644 index 0000000..c5bd977 --- /dev/null +++ b/Core/Tests/ServiceTests/ExtractSelectedCodeTests.swift @@ -0,0 +1,56 @@ +import SuggestionBasic +import XCTest +@testable import Service +@testable import XPCShared + +class ExtractSelectedCodeTests: XCTestCase { + func test_empty_selection() { + let selection = EditorContent.Selection( + start: CursorPosition(line: 0, character: 0), + end: CursorPosition(line: 0, character: 0) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = selectedCode(in: selection, for: lines) + XCTAssertEqual(result, "") + } + + func test_single_line_selection() { + let selection = EditorContent.Selection( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = selectedCode(in: selection, for: lines) + XCTAssertEqual(result, "foo = ") + } + + func test_single_line_selection_at_line_end() { + let selection = EditorContent.Selection( + start: CursorPosition(line: 0, character: 8), + end: CursorPosition(line: 0, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = selectedCode(in: selection, for: lines) + XCTAssertEqual(result, "= 1") + } + + func test_multi_line_selection() { + let selection = EditorContent.Selection( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = selectedCode(in: selection, for: lines) + XCTAssertEqual(result, "foo = 1\nlet bar = 2") + } + + func test_invalid_selection() { + let selection = EditorContent.Selection( + start: CursorPosition(line: 1, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1", "let bar = 2"] + let result = selectedCode(in: selection, for: lines) + XCTAssertEqual(result, "") + } +} diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift new file mode 100644 index 0000000..65fb242 --- /dev/null +++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift @@ -0,0 +1,272 @@ +import Foundation +import SuggestionBasic +import XCTest +import WorkspaceSuggestionService + +@testable import Service +@testable import Workspace + +class FilespaceSuggestionInvalidationTests: XCTestCase { + @WorkspaceActor + func prepare( + lines: [String] = [ + "let one = 1\n", + "\n", + "let three = 3\n", + ], + cursorPosition: CursorPosition = .init(line: 1, character: 0), + suggestionText: String = "let two = 2", + 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")) + filespace.suggestions = [ + .init( + id: "", + text: suggestionText, + position: cursorPosition, + range: range + ), + ] + let snapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) + filespace.suggestionSourceSnapshot = snapshot + return (filespace, snapshot) + } + + func testUnchangedDocument_IsValid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 0) + ) + + XCTAssertTrue(isValid) + XCTAssertNotNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertEqual(snapshot, priorSnapshot) + } + + func testTypingIntoCompletion_IsValid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let \n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 4) + ) + + XCTAssertTrue(isValid) + XCTAssertNotNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertEqual(snapshot, priorSnapshot) + } + + func testTypingIntoMultibyteCharacterCompletion_IsValid() async throws { + let (filespace, priorSnapshot) = try await prepare( + suggestionText: "let t๐ŸŽ†๐ŸŽ† = 2" + ) + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let t๐ŸŽ†๐ŸŽ†\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 7) + ) + + XCTAssertTrue(isValid) + XCTAssertNotNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertEqual(snapshot, priorSnapshot) + } + + func testTypingNonMatchingText_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "var \n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 4) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testMiddleOfLinePosition_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let \n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 2) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testCompletingBracesAfterCursor_IsValid() async throws { + let (filespace, priorSnapshot) = try await prepare( + suggestionText: "let two = (2, 2)" + ) + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let two = (2)\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 12) + ) + + XCTAssertTrue(isValid) + XCTAssertNotNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertEqual(snapshot, priorSnapshot) + } + + func testTypingFullCompletion_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let two = 2\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 11) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testTypingPastCompletion_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let two = 22\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 12) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testAlteringOtherDocumentParts_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "\n", + ], + cursorPosition: .init(line: 1, character: 0) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testNotPresentingSuggestion_IsInvalid() async throws { + let (filespace, _) = try await prepare() + await filespace.reset() + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 0) + ) + + XCTAssertFalse(isValid) + } + + func testCompletionNotAtStartOfLine_IsInvalid() async throws { + let (filespace, priorSnapshot) = try await prepare( + lines: [ + "let one = 1\n", + "var \n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 4), + suggestionText: "two = 2", + range: .init(startPair: (1, 4), endPair: (1, 4)) + ) + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let \n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 4) + ) + + XCTAssertFalse(isValid) + XCTAssertNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertNotEqual(snapshot, priorSnapshot) + } + + func testCompletionReplacingBracesAfterCursor_IsValid() async throws { + let (filespace, priorSnapshot) = try await prepare( + lines: [ + "let one = 1\n", + "let two = (2, (2))\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 16), + suggestionText: "let two = (2, (2, 2))", + range: .init(startPair: (1, 0), endPair: (1, 18)) + ) + + let isValid = await filespace.validateSuggestions( + lines: [ + "let one = 1\n", + "let two = (2, (2,))\n", + "let three = 3\n", + ], + cursorPosition: .init(line: 1, character: 17) + ) + + XCTAssertTrue(isValid) + XCTAssertNotNil(filespace.presentingSuggestion) + let snapshot = await filespace.suggestionSourceSnapshot + XCTAssertEqual(snapshot, priorSnapshot) + } +} + diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift new file mode 100644 index 0000000..dfdb4b3 --- /dev/null +++ b/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift @@ -0,0 +1,866 @@ +import SuggestionBasic +import XCTest + +@testable import SuggestionInjector + +final class AcceptSuggestionTests: XCTestCase { + func test_accept_suggestion_single_line() async throws { + let content = """ + struct Cat { + + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 1), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 0) + ) + ) + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 1) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo, + suggestionLineLimit: 1 + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 4)) + XCTAssertEqual( + lines.joined(separator: ""), + [ + "struct Cat {", + " var name: String", + " ", + "}", + "" + ].joined(separator: "\n"), + "There is always a new line at the end of each line! When you join them, it will look like this" + ) + } + + func test_accept_suggestion_no_overlap() async throws { + let content = """ + struct Cat { + + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 1), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 0) + ) + ) + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 1) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual( + lines.joined(separator: ""), + """ + struct Cat { + var name: String + var age: String + } + + """, + "There is always a new line at the end of each line! When you join them, it will look like this" + ) + } + + func test_accept_suggestion_start_from_previous_line() async throws { + let content = """ + struct Cat { + } + """ + let text = """ + struct Cat { + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap() async throws { + let content = """ + struct Cat { + var name + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 12) + ) + ) + + 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 + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing() async throws { + let content = """ + struct Cat { + var name: Str + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 12), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 12) + ) + ) + + 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 + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws { + let content = """ + print("") + """ + let text = """ + print("Hello World!") + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 21)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("Hello World!") + + """) + } + + func test_accept_suggestion_overlap_continue_typing_suggestion_in_the_middle() async throws { + let content = """ + print("He") + """ + let text = """ + print("Hello World! + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("Hello World!") + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_suffix_typed_suggestion_has_multiple_lines( + ) async throws { + let content = """ + struct Cat {} + """ + let text = """ + struct Cat { + var name: String + var kind: String + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 12) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 3, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Cat { + var name: String + var kind: String + } + + """) + } + + func test_propose_suggestion_partial_overlap() async throws { + let content = "func quickSort() {}}" + let text = """ + func quickSort() { + var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] + var left = 0 + var right = array.count - 1 + quickSort(&array, left, right) + print(array) + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 18), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 20) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 18) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 6, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + func quickSort() { + var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] + var left = 0 + var right = array.count - 1 + quickSort(&array, left, right) + print(array) + } + + """) + } + + func test_no_overlap_append_to_the_end() async throws { + let content = "func quickSort() {" + let text = """ + var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] + var left = 0 + var right = array.count - 1 + quickSort(&array, left, right) + print(array) + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 18), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 0) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 18) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 6, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + func quickSort() { + var array = [1, 3, 2, 4, 5, 6, 7, 8, 9, 10] + var left = 0 + var right = array.count - 1 + quickSort(&array, left, right) + print(array) + } + + """) + } + + func test_replacing_multiple_lines() async throws { + let content = """ + struct Cat { + func speak() { print("meow") } + } + """ + let text = """ + struct Dog { + func speak() { + print("woof") + } + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 2, character: 1) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct Dog { + func speak() { + print("woof") + } + } + + """) + } + + func test_replacing_multiple_lines_in_the_middle() async throws { + let content = """ + protocol Animal { + func speak() + } + + struct Cat: Animal { + func speak() { print("meow") } + } + + func foo() {} + """ + let text = """ + Dog { + func speak() { + print("woof") + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 5, character: 34), + range: .init( + start: .init(line: 4, character: 7), + end: .init(line: 5, character: 34) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 7, character: 5)) + XCTAssertEqual(lines.joined(separator: ""), """ + protocol Animal { + func speak() + } + + struct Dog { + func speak() { + print("woof") + } + } + + func foo() {} + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character( + ) async throws { + let content = """ + apiKeyName: ,, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName,, + + """) + } + + func test_remove_the_first_adjacent_placeholder_in_the_last_line( + ) async throws { + let content = """ + apiKeyName: <#T##value: BinaryInteger##BinaryInteger#> <#Hello#>, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "apiKeyName: azureOpenAIAPIKeyName", + position: .init(line: 0, character: 12), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 12) + ) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 33)) + XCTAssertEqual(lines.joined(separator: ""), """ + apiKeyName: azureOpenAIAPIKeyName <#Hello#>, + + """) + } + + func test_accept_suggestion_start_from_previous_line_has_emoji_inside() async throws { + let content = """ + struct ๐Ÿ˜น๐Ÿ˜น { + } + """ + let text = """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 13), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 13) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_with_emoji_in_the_previous_code() async throws { + let content = """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 13), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 13) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name: String + var age: String + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing_has_emoji_inside() async throws { + let content = """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name: Str + } + """ + let text = """ + var name: String + var age: String + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 1, character: 13), + range: .init( + start: .init(line: 1, character: 0), + end: .init(line: 1, character: 13) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 1, character: 13) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 2, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct ๐Ÿ˜น๐Ÿ˜น { + var name: String + var age: String + } + + """) + } + + func test_replacing_multiple_lines_with_emoji() async throws { + let content = """ + struct ๐Ÿ˜น๐Ÿ˜น { + func speak() { print("meow") } + } + """ + let text = """ + struct ๐Ÿถ๐Ÿถ { + func speak() { + print("woof") + } + } + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 7), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 2, character: 1) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 4, character: 1)) + XCTAssertEqual(lines.joined(separator: ""), """ + struct ๐Ÿถ๐Ÿถ { + func speak() { + print("woof") + } + } + + """) + } + + func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle() async throws { + let content = """ + print("๐Ÿถ") + """ + let text = """ + print("๐Ÿถllo ๐Ÿถrld! + """ + let suggestion = CodeSuggestion( + id: "", + text: text, + position: .init(line: 0, character: 6), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 6) + ) + ) + + var extraInfo = SuggestionInjector.ExtraInfo() + var lines = content.breakIntoEditorStyleLines() + var cursor = CursorPosition(line: 0, character: 7) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + XCTAssertTrue(extraInfo.didChangeContent) + XCTAssertTrue(extraInfo.didChangeCursorPosition) + XCTAssertNil(extraInfo.suggestionRange) + XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications)) + XCTAssertEqual(cursor, .init(line: 0, character: 19)) + XCTAssertEqual(lines.joined(separator: ""), """ + print("๐Ÿถllo ๐Ÿถrld!") + + """) + } + + func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character_with_emoji( + ) async throws { + let content = """ + ๐ŸถKeyName: ,, + """ + + let suggestion = CodeSuggestion( + id: "", + text: "๐ŸถKeyName: azure๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จAIAPIKeyName", + position: .init(line: 0, character: 11), + range: .init( + start: .init(line: 0, character: 0), + end: .init(line: 0, character: 11) + ) + ) + + var lines = content.breakIntoEditorStyleLines() + var extraInfo = SuggestionInjector.ExtraInfo() + var cursor = CursorPosition(line: 5, character: 34) + SuggestionInjector().acceptSuggestion( + intoContentWithoutSuggestion: &lines, + cursorPosition: &cursor, + completion: suggestion, + extraInfo: &extraInfo + ) + + XCTAssertEqual(cursor, .init(line: 0, character: 36)) + XCTAssertEqual(lines.joined(separator: ""), """ + ๐ŸถKeyName: azure๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จAIAPIKeyName,, + + """) + } +} + +extension String { + func breakIntoEditorStyleLines() -> [String] { + split(separator: "\n", omittingEmptySubsequences: false).map { $0 + "\n" } + } +} + diff --git a/Core/Tests/SuggestionWidgetTests/File.swift b/Core/Tests/SuggestionWidgetTests/File.swift new file mode 100644 index 0000000..fecc4ab --- /dev/null +++ b/Core/Tests/SuggestionWidgetTests/File.swift @@ -0,0 +1 @@ +import Foundation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..a7b16c4 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,82 @@ +# Development + +## Prerequisites + +Requires Node installed and `npm` available on your system path, e.g. + +```sh +sudo ln -s `which npm` /usr/local/bin +``` + +## Local Language Server + +To run the language server locally create a `Config.local.xcconfig` file with two config values: + +```xcconfig +LANGUAGE_SERVER_PATH=~/code/copilot-client +NODE_PATH=/opt/path/to/node +``` + +`LANGUAGE_SERVER_PATH` should point to the path where the copilot-client repo is +checked out and `$(LANGUAGE_SERVER_PATH)/dist/language-server.js` must exist +(run `npm run build`). + +`NODE_PATH` should point to where node is installed. It can be omitted if +`/usr/bin/env node` will resolves directly. + +## Targets + +### Copilot for Xcode + +Copilot for Xcode is the host app containing both the XPCService and the editor extension. It provides the settings UI. + +### EditorExtension + +As its name suggests, the Xcode source editor extension. Its sole purpose is to forward editor content to the XPCService for processing, and update the editor with the returned content. Due to the sandboxing requirements for editor extensions, it has to communicate with a trusted, non-sandboxed XPCService (CommunicationBridge and ExtensionService) to bypass the limitations. The XPCService service name must be included in the `com.apple.security.temporary-exception.mach-lookup.global-name` entitlements. + +### ExtensionService + +The `ExtensionService` is a program that operates in the background. All features are implemented in this target. + +### CommunicationBridge + +It's responsible for maintaining the communication between the Copilot for Xcode/EditorExtension and ExtensionService. + +### Core and Tool + +Most of the logics are implemented inside the package `Core` and `Tool`. + +- The `Service` contains the implementations of the ExtensionService target. +- The `HostApp` contains the implementations of the Copilot for Xcode target. + +## Building and Archiving the App + +1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift. +2. Build or archive the Copilot for Xcode target. +3. If Xcode complains that the pro package doesn't exist, please remove the package from the project. + +## Testing Source Editor Extension + +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. + +## 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. + +## Unit Tests + +To run unit tests, just run test from the `Copilot for Xcode` target. + +For new tests, they should be added to the `TestPlan.xctestplan`. + +## Code Style + +We use SwiftFormat to format the code. + +The source code mostly follows the [Ray Wenderlich Style Guide](https://github.com/raywenderlich/swift-style-guide) very closely with the following exception: + +- Use the Xcode default of 4 spaces for indentation. + +## App Versioning + +The app version and all targets' version in controlled by `Version.xcconfig`. \ No newline at end of file diff --git a/Docs/AppIcon.png b/Docs/AppIcon.png new file mode 100644 index 0000000..88b20d1 Binary files /dev/null and b/Docs/AppIcon.png differ diff --git a/Docs/accessibility-permission-request.png b/Docs/accessibility-permission-request.png new file mode 100644 index 0000000..302fd0b Binary files /dev/null and b/Docs/accessibility-permission-request.png differ diff --git a/Docs/accessibility-permission.png b/Docs/accessibility-permission.png new file mode 100644 index 0000000..7a2ebfa Binary files /dev/null and b/Docs/accessibility-permission.png differ diff --git a/Docs/background-item.png b/Docs/background-item.png new file mode 100644 index 0000000..9eea5b3 Binary files /dev/null and b/Docs/background-item.png differ diff --git a/Docs/demo.gif b/Docs/demo.gif new file mode 100644 index 0000000..5310fa1 Binary files /dev/null and b/Docs/demo.gif differ diff --git a/Docs/device-code.png b/Docs/device-code.png new file mode 100644 index 0000000..a345a73 Binary files /dev/null and b/Docs/device-code.png differ diff --git a/Docs/dmg-open.png b/Docs/dmg-open.png new file mode 100644 index 0000000..2a481f0 Binary files /dev/null and b/Docs/dmg-open.png differ diff --git a/Docs/downloaded-from-internet.png b/Docs/downloaded-from-internet.png new file mode 100644 index 0000000..cd4b1ea Binary files /dev/null and b/Docs/downloaded-from-internet.png differ diff --git a/Docs/extension-permission.png b/Docs/extension-permission.png new file mode 100644 index 0000000..e85c319 Binary files /dev/null and b/Docs/extension-permission.png differ diff --git a/Docs/signin-button.png b/Docs/signin-button.png new file mode 100644 index 0000000..ac566c9 Binary files /dev/null and b/Docs/signin-button.png differ diff --git a/Docs/update-message.png b/Docs/update-message.png new file mode 100644 index 0000000..3503586 Binary files /dev/null and b/Docs/update-message.png differ diff --git a/Docs/xcode-menu.png b/Docs/xcode-menu.png new file mode 100644 index 0000000..ccebfc5 Binary files /dev/null and b/Docs/xcode-menu.png differ diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift new file mode 100644 index 0000000..51bea4a --- /dev/null +++ b/EditorExtension/AcceptPromptToCodeCommand.swift @@ -0,0 +1,31 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept Prompt to Code" } + + 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.getPromptToCodeAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/EditorExtension/AcceptSuggestionCommand.swift b/EditorExtension/AcceptSuggestionCommand.swift new file mode 100644 index 0000000..a1ea71f --- /dev/null +++ b/EditorExtension/AcceptSuggestionCommand.swift @@ -0,0 +1,33 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit +import XPCShared + +class AcceptSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Accept 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.getSuggestionAcceptedCode( + editorContent: .init(invocation) + ) { + invocation.accept(content) + } + completionHandler(nil) + }.value) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} + diff --git a/EditorExtension/CloseIdleTabsCommand.swift b/EditorExtension/CloseIdleTabsCommand.swift new file mode 100644 index 0000000..0e9537e --- /dev/null +++ b/EditorExtension/CloseIdleTabsCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class CloseIdleTabsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Close Idle Tabs" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.postNotification(name: "CloseIdleTabsOfXcodeWindow") + } + } +} + diff --git a/EditorExtension/CustomCommand.swift b/EditorExtension/CustomCommand.swift new file mode 100644 index 0000000..0a43a51 --- /dev/null +++ b/EditorExtension/CustomCommand.swift @@ -0,0 +1,23 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class CustomCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String = "" + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.customCommand( + id: customCommandMap[invocation.commandIdentifier] ?? "", + editorContent: .init(invocation) + ) + } + } +} + diff --git a/EditorExtension/EditorExtension.entitlements b/EditorExtension/EditorExtension.entitlements new file mode 100644 index 0000000..776babc --- /dev/null +++ b/EditorExtension/EditorExtension.entitlements @@ -0,0 +1,17 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(BUNDLE_IDENTIFIER_BASE).CommunicationBridge + $(BUNDLE_IDENTIFIER_BASE).ExtensionService + + + diff --git a/EditorExtension/GetSuggestionsCommand.swift b/EditorExtension/GetSuggestionsCommand.swift new file mode 100644 index 0000000..6be1c41 --- /dev/null +++ b/EditorExtension/GetSuggestionsCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class GetSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Get Suggestions" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getSuggestedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/Helpers.swift b/EditorExtension/Helpers.swift new file mode 100644 index 0000000..8851c27 --- /dev/null +++ b/EditorExtension/Helpers.swift @@ -0,0 +1,98 @@ +import SuggestionBasic +import Foundation +import XcodeKit +import XPCShared + +extension XCSourceEditorCommandInvocation { + func mutateCompleteBuffer(modifications: [Modification], restoringSelections restore: Bool) { + if restore { + let selectionsRangesToRestore = buffer.selections + .compactMap { $0 as? XCSourceTextRange } + buffer.selections.removeAllObjects() + buffer.lines.apply(modifications) + for range in selectionsRangesToRestore { + buffer.selections.add(range) + } + } else { + buffer.lines.apply(modifications) + } + } + + func accept(_ updatedContent: UpdatedContent) { + if let newSelection = updatedContent.newSelection { + mutateCompleteBuffer( + modifications: updatedContent.modifications, + restoringSelections: false + ) + buffer.selections.removeAllObjects() + buffer.selections.add(XCSourceTextRange( + start: .init(line: newSelection.start.line, column: newSelection.start.character), + end: .init(line: newSelection.end.line, column: newSelection.end.character) + )) + } else { + mutateCompleteBuffer( + modifications: updatedContent.modifications, + restoringSelections: true + ) + } + } +} + +extension EditorContent { + init(_ invocation: XCSourceEditorCommandInvocation) { + let buffer = invocation.buffer + self.init( + content: buffer.completeBuffer, + lines: buffer.lines as? [String] ?? [], + uti: buffer.contentUTI, + cursorPosition: ((buffer.selections.lastObject as? XCSourceTextRange)?.end).map { + CursorPosition(line: $0.line, character: $0.column) + } ?? CursorPosition(line: 0, character: 0), + cursorOffset: -1, + selections: buffer.selections.map { + let sl = ($0 as? XCSourceTextRange)?.start.line ?? 0 + let sc = ($0 as? XCSourceTextRange)?.start.column ?? 0 + let el = ($0 as? XCSourceTextRange)?.end.line ?? 0 + let ec = ($0 as? XCSourceTextRange)?.end.column ?? 0 + + return Selection( + start: CursorPosition( line: sl, character: sc ), + end: CursorPosition( line: el, character: ec ) + ) + }, + tabSize: buffer.tabWidth, + indentSize: buffer.indentationWidth, + usesTabsForIndentation: buffer.usesTabsForIndentation + ) + } +} + +/// https://gist.github.com/swhitty/9be89dfe97dbb55c6ef0f916273bbb97 +extension Task where Failure == Error { + // Start a new Task with a timeout. If the timeout expires before the operation is + // completed then the task is cancelled and an error is thrown. + init( + priority: TaskPriority? = nil, + timeout: TimeInterval, + operation: @escaping @Sendable () async throws -> Success + ) { + self = Task(priority: priority) { + try await withThrowingTaskGroup(of: Success.self) { group -> Success in + group.addTask(operation: operation) + group.addTask { + try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw TimeoutError() + } + guard let success = try await group.next() else { + throw _Concurrency.CancellationError() + } + group.cancelAll() + return success + } + } + } +} + +private struct TimeoutError: LocalizedError { + var errorDescription: String? = "Task timed out before completion" +} diff --git a/EditorExtension/Info.plist b/EditorExtension/Info.plist new file mode 100644 index 0000000..12cf1cc --- /dev/null +++ b/EditorExtension/Info.plist @@ -0,0 +1,46 @@ + + + + + APPLICATION_SUPPORT_FOLDER + $(APPLICATION_SUPPORT_FOLDER) + BUNDLE_IDENTIFIER_BASE + $(BUNDLE_IDENTIFIER_BASE) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(EXTENSION_BUNDLE_DISPLAY_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(EXTENSION_BUNDLE_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + HOST_APP_NAME + $(HOST_APP_NAME) + NSExtension + + NSExtensionAttributes + + XCSourceEditorCommandDefinitions + + XCSourceEditorExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SourceEditorExtension + + NSExtensionPointIdentifier + com.apple.dt.Xcode.extension.source-editor + + NSHumanReadableCopyright + + TEAM_ID_PREFIX + $(TeamIdentifierPrefix) + + diff --git a/EditorExtension/NextSuggestionCommand.swift b/EditorExtension/NextSuggestionCommand.swift new file mode 100644 index 0000000..f07f401 --- /dev/null +++ b/EditorExtension/NextSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class NextSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Next Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getNextSuggestedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/OpenChat.swift b/EditorExtension/OpenChat.swift new file mode 100644 index 0000000..fccdc3f --- /dev/null +++ b/EditorExtension/OpenChat.swift @@ -0,0 +1,19 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class OpenChatCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Open Chat" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.openChat(editorContent: .init(invocation)) + } + } +} diff --git a/EditorExtension/OpenSettingsCommand.swift b/EditorExtension/OpenSettingsCommand.swift new file mode 100644 index 0000000..2350171 --- /dev/null +++ b/EditorExtension/OpenSettingsCommand.swift @@ -0,0 +1,68 @@ +// +// OpenSettingsCommand.swift +// EditorExtension +// +// Opens the settings app +// + +import Foundation +import XcodeKit + +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" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + 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 + } + } + 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" +} diff --git a/EditorExtension/PrefetchSuggestionsCommand.swift b/EditorExtension/PrefetchSuggestionsCommand.swift new file mode 100644 index 0000000..bc43c40 --- /dev/null +++ b/EditorExtension/PrefetchSuggestionsCommand.swift @@ -0,0 +1,19 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class PrefetchSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Prefetch Suggestions" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + await service.prefetchRealtimeSuggestions(editorContent: .init(invocation)) + } + } +} diff --git a/EditorExtension/PreviousSuggestionCommand.swift b/EditorExtension/PreviousSuggestionCommand.swift new file mode 100644 index 0000000..61894ba --- /dev/null +++ b/EditorExtension/PreviousSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class PreviousSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Previous Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getPreviousSuggestedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift new file mode 100644 index 0000000..13e4f3b --- /dev/null +++ b/EditorExtension/PromptToCodeCommand.swift @@ -0,0 +1,19 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Prompt to Code" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.promptToCode(editorContent: .init(invocation)) + } + } +} diff --git a/EditorExtension/RejectSuggestionCommand.swift b/EditorExtension/RejectSuggestionCommand.swift new file mode 100644 index 0000000..d109123 --- /dev/null +++ b/EditorExtension/RejectSuggestionCommand.swift @@ -0,0 +1,20 @@ +import Client +import Foundation +import SuggestionBasic +import XcodeKit + +class RejectSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Decline Suggestion" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getSuggestionRejectedCode(editorContent: .init(invocation)) + } + } +} + diff --git a/EditorExtension/SeparatorCommand.swift b/EditorExtension/SeparatorCommand.swift new file mode 100644 index 0000000..79e4b13 --- /dev/null +++ b/EditorExtension/SeparatorCommand.swift @@ -0,0 +1,20 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class SeparatorCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String = "" + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + } + + func named(_ name: String) -> Self { + self.name = name + return self + } +} diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift new file mode 100644 index 0000000..a9d252f --- /dev/null +++ b/EditorExtension/SourceEditorExtension.swift @@ -0,0 +1,90 @@ +import Client +import Foundation +import GitHubCopilotService +import Preferences +import XcodeKit + +#if canImport(PreferencesPlus) +import PreferencesPlus +#endif + +class SourceEditorExtension: NSObject, XCSourceEditorExtension { + var builtin: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ + AcceptSuggestionCommand(), + RejectSuggestionCommand(), + GetSuggestionsCommand(), + NextSuggestionCommand(), + PreviousSuggestionCommand(), + SyncTextSettingsCommand(), + ToggleRealtimeSuggestionsCommand(), + ].map(makeCommandDefinition) + } + + var chat: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ + OpenChatCommand() + ].map(makeCommandDefinition) + } + + var additionalBuiltin: [[XCSourceEditorCommandDefinitionKey: Any]] { + [ + OpenSettingsCommand(), + ].map(makeCommandDefinition) + } + + var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] { + var definitions = builtin + + if FeatureFlagNotifierImpl.shared.featureFlags.chat { + definitions += chat + } + + definitions += additionalBuiltin + + return definitions + } + + func extensionDidFinishLaunching() { + #if DEBUG + // In a debug build, we usually want to use the XPC service run from Xcode. + #else + // When the source extension is initialized + // we can call a random command to wake up the XPC service. + Task.detached { + try await Task.sleep(nanoseconds: 1_000_000_000) + let service = try getService() + _ = try await service.getXPCServiceVersion() + } + #endif + } +} + +let identifierPrefix: String = Bundle.main.bundleIdentifier ?? "" + +var customCommandMap = [String: String]() + +protocol CommandType: AnyObject { + var commandClassName: String { get } + var identifier: String { get } + var name: String { get } +} + +extension CommandType where Self: NSObject { + var commandClassName: String { Self.className() } + var identifier: String { commandClassName } +} + +extension CommandType { + func makeCommandDefinition() -> [XCSourceEditorCommandDefinitionKey: Any] { + [.classNameKey: commandClassName, + .identifierKey: identifierPrefix + identifier, + .nameKey: name] + } +} + +func makeCommandDefinition(_ commandType: CommandType) + -> [XCSourceEditorCommandDefinitionKey: Any] +{ + commandType.makeCommandDefinition() +} diff --git a/EditorExtension/SyncTextSettingsCommand.swift b/EditorExtension/SyncTextSettingsCommand.swift new file mode 100644 index 0000000..f1c5456 --- /dev/null +++ b/EditorExtension/SyncTextSettingsCommand.swift @@ -0,0 +1,19 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class SyncTextSettingsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Sync Text Settings" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getRealtimeSuggestedCode(editorContent: .init(invocation)) + } + } +} diff --git a/EditorExtension/ToggleRealtimeSuggestionsCommand.swift b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift new file mode 100644 index 0000000..690143d --- /dev/null +++ b/EditorExtension/ToggleRealtimeSuggestionsCommand.swift @@ -0,0 +1,25 @@ +import Client +import SuggestionBasic +import Foundation +import XcodeKit + +class ToggleRealtimeSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Enable/Disable Completions" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + Task { + do { + let service = try getService() + try await service.toggleRealtimeSuggestion() + completionHandler(nil) + } catch is CancellationError { + completionHandler(nil) + } catch { + completionHandler(error) + } + } + } +} diff --git a/ExtensionPoint.appextensionpoint b/ExtensionPoint.appextensionpoint new file mode 100644 index 0000000..31f4275 --- /dev/null +++ b/ExtensionPoint.appextensionpoint @@ -0,0 +1,11 @@ + + + + + com.github.CopilotForXcode.ExtensionService.Extension + + EXPresentsUserInterface + + + + diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift new file mode 100644 index 0000000..a278af1 --- /dev/null +++ b/ExtensionService/AppDelegate+Menu.swift @@ -0,0 +1,302 @@ +import AppKit +import Foundation +import Preferences +import XcodeInspector +import Logger + +extension AppDelegate { + fileprivate var statusBarMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("statusBarMenu") + } + + fileprivate var xcodeInspectorDebugMenuIdentifier: NSUserInterfaceItemIdentifier { + .init("xcodeInspectorDebugMenu") + } + + fileprivate var accessibilityAPIPermissionMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("accessibilityAPIPermissionMenuItem") + } + + fileprivate var sourceEditorDebugMenu: NSUserInterfaceItemIdentifier { + .init("sourceEditorDebugMenu") + } + + fileprivate var toggleCompletionsMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("toggleCompletionsMenuItem") + } + + fileprivate var copilotStatusMenuItemIdentifier: NSUserInterfaceItemIdentifier { + .init("copilotStatusMenuItem") + } + + @MainActor + @objc func buildStatusBarMenu() { + let statusBar = NSStatusBar.system + statusBarItem = statusBar.statusItem( + withLength: NSStatusItem.squareLength + ) + statusBarItem.button?.image = NSImage(named: "MenuBarIcon") + + 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 openCopilotForXcode = NSMenuItem( + title: "Open \(hostAppName) Settings", + action: #selector(openCopilotForXcode), + keyEquivalent: "" + ) + + let xcodeInspectorDebug = NSMenuItem( + title: "Xcode Inspector Debug", + action: nil, + keyEquivalent: "" + ) + + let xcodeInspectorDebugMenu = NSMenu(title: "Xcode Inspector Debug") + xcodeInspectorDebugMenu.identifier = xcodeInspectorDebugMenuIdentifier + xcodeInspectorDebug.submenu = xcodeInspectorDebugMenu + xcodeInspectorDebug.isHidden = false + + let accessibilityAPIPermission = NSMenuItem( + title: "Accessibility Permission: N/A", + action: nil, + keyEquivalent: "" + ) + accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier + + let quitItem = NSMenuItem( + title: "Quit", + action: #selector(quit), + keyEquivalent: "" + ) + quitItem.target = self + + let toggleCompletions = NSMenuItem( + title: "Enable/Disable Completions", + action: #selector(toggleCompletionsEnabled), + keyEquivalent: "" + ) + toggleCompletions.identifier = toggleCompletionsMenuItemIdentifier; + + let copilotStatus = NSMenuItem( + title: "Copilot Connection: Checking...", + action: nil, + keyEquivalent: "" + ) + copilotStatus.identifier = copilotStatusMenuItemIdentifier + + let openDocs = NSMenuItem( + title: "View Copilot Documentation...", + action: #selector(openCopilotDocs), + keyEquivalent: "" + ) + + let openForum = NSMenuItem( + title: "View Copilot Feedback Forum...", + action: #selector(openCopilotForum), + keyEquivalent: "" + ) + + statusBarMenu.addItem(openCopilotForXcode) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(checkForUpdate) + statusBarMenu.addItem(toggleCompletions) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(copilotStatus) + statusBarMenu.addItem(accessibilityAPIPermission) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(openDocs) + statusBarMenu.addItem(openForum) + statusBarMenu.addItem(.separator()) + statusBarMenu.addItem(xcodeInspectorDebug) + statusBarMenu.addItem(quitItem) + + statusBarMenu.delegate = self + xcodeInspectorDebugMenu.delegate = self + } +} + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + switch menu.identifier { + case statusBarMenuIdentifier: + if let xcodeInspectorDebug = menu.items.first(where: { item in + item.submenu?.identifier == xcodeInspectorDebugMenuIdentifier + }) { + xcodeInspectorDebug.isHidden = !UserDefaults.shared + .value(for: \.enableXcodeInspectorDebugMenu) + } + + if let toggleCompletions = menu.items.first(where: { item in + item.identifier == toggleCompletionsMenuItemIdentifier + }) { + toggleCompletions.title = "\(UserDefaults.shared.value(for: \.realtimeSuggestionToggle) ? "Disable" : "Enable") Completions" + } + + if let accessibilityAPIPermission = menu.items.first(where: { item in + item.identifier == accessibilityAPIPermissionMenuItemIdentifier + }) { + AXIsProcessTrusted() + accessibilityAPIPermission.title = + "Accessibility Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")" + } + + statusChecker.updateStatusInBackground(notify: { (status: String, isOk: Bool) in + if let statusItem = menu.items.first(where: { item in + item.identifier == self.copilotStatusMenuItemIdentifier + }) { + statusItem.title = "Copilot Connection: \(isOk ? "Connected" : status)" + } + }) + + case xcodeInspectorDebugMenuIdentifier: + let inspector = XcodeInspector.shared + menu.items.removeAll() + menu.items + .append(.text("Active Project: \(inspector.activeProjectRootURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Workspace: \(inspector.activeWorkspaceURL?.path ?? "N/A")")) + menu.items + .append(.text("Active Document: \(inspector.activeDocumentURL?.path ?? "N/A")")) + + if let focusedWindow = inspector.focusedWindow { + menu.items.append(.text( + "Active Window: \(focusedWindow.uiElement.identifier)" + )) + } else { + menu.items.append(.text("Active Window: N/A")) + } + + if let focusedElement = inspector.focusedElement { + menu.items.append(.text( + "Focused Element: \(focusedElement.description)" + )) + } else { + menu.items.append(.text("Focused Element: N/A")) + } + + if let sourceEditor = inspector.focusedEditor { + let label = sourceEditor.element.description + menu.items + .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) + } else { + menu.items.append(.text("Active Source Editor: N/A")) + } + + menu.items.append(.separator()) + + for xcode in inspector.xcodes { + let item = NSMenuItem( + title: "Xcode \(xcode.processIdentifier)", + action: nil, + keyEquivalent: "" + ) + menu.addItem(item) + let xcodeMenu = NSMenu() + item.submenu = xcodeMenu + xcodeMenu.items.append(.text("Is Active: \(xcode.isActive)")) + xcodeMenu.items + .append(.text("Active Project: \(xcode.projectRootURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Workspace: \(xcode.workspaceURL?.path ?? "N/A")")) + xcodeMenu.items + .append(.text("Active Document: \(xcode.documentURL?.path ?? "N/A")")) + + for (key, workspace) in xcode.realtimeWorkspaces { + let workspaceItem = NSMenuItem( + title: "Workspace \(key)", + action: nil, + keyEquivalent: "" + ) + xcodeMenu.items.append(workspaceItem) + let workspaceMenu = NSMenu() + workspaceItem.submenu = workspaceMenu + let tabsItem = NSMenuItem( + title: "Tabs", + action: nil, + keyEquivalent: "" + ) + workspaceMenu.addItem(tabsItem) + let tabsMenu = NSMenu() + tabsItem.submenu = tabsMenu + for tab in workspace.tabs { + tabsMenu.addItem(.text(tab)) + } + } + } + + menu.items.append(.separator()) + + menu.items.append(NSMenuItem( + title: "Restart Xcode Inspector", + action: #selector(restartXcodeInspector), + keyEquivalent: "" + )) + + default: + break + } + } +} + +import XPCShared + +private extension AppDelegate { + @objc func restartXcodeInspector() { + Task { + await XcodeInspector.shared.restart(cleanUp: true) + } + } + + @objc func toggleCompletionsEnabled() { + Task { + let initialSetting = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + do { + let service = getXPCExtensionService() + try await service.toggleRealtimeSuggestion() + } catch { + Logger.service.error("Failed to toggle completions enabled via XPC: \(error)") + UserDefaults.shared.set(!initialSetting, for: \.realtimeSuggestionToggle) + } + } + } + + @objc func openCopilotDocs() { + if let urlString = Bundle.main.object(forInfoDictionaryKey: "COPILOT_DOCS_URL") as? String { + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + } + + @objc func openCopilotForum() { + if let urlString = Bundle.main.object(forInfoDictionaryKey: "COPILOT_FORUM_URL") as? String { + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + } +} + +private extension NSMenuItem { + static func text(_ text: String) -> NSMenuItem { + let item = NSMenuItem( + title: text, + action: nil, + keyEquivalent: "" + ) + item.isEnabled = false + return item + } +} + diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift new file mode 100644 index 0000000..325f131 --- /dev/null +++ b/ExtensionService/AppDelegate.swift @@ -0,0 +1,176 @@ +import Combine +import FileChangeChecker +import GitHubCopilotService +import LaunchAgentManager +import Logger +import Preferences +import Service +import ServiceManagement +import SwiftUI +import UpdateChecker +import UserDefaultsObserver +import UserNotifications +import XcodeInspector +import XPCShared + +let bundleIdentifierBase = Bundle.main + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +let serviceIdentifier = bundleIdentifierBase + ".ExtensionService" + +@main +class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + let service = Service.shared + var statusBarItem: NSStatusItem! + var xpcController: XPCController? + let updateChecker = + UpdateChecker( + hostBundle: locateHostBundleURL(url: Bundle.main.bundleURL) + .flatMap(Bundle.init(url:)) + ) + let statusChecker: AuthStatusChecker = AuthStatusChecker() + var xpcExtensionService: XPCExtensionService? + private var cancellables = Set() + + func applicationDidFinishLaunching(_: Notification) { + if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } + _ = XcodeInspector.shared + service.start() + AXIsProcessTrustedWithOptions([ + kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, + ] as CFDictionary) + setupQuitOnUpdate() + setupQuitOnUserTerminated() + setupQuitOnFeatureFlag() + xpcController = .init() + Logger.service.info("XPC Service started.") + NSApp.setActivationPolicy(.accessory) + buildStatusBarMenu() + } + + @objc func quit() { + Task { @MainActor in + await service.prepareForExit() + await xpcController?.quit() + NSApp.terminate(self) + } + } + + @objc func openCopilotForXcode() { + let task = Process() + if let appPath = locateHostBundleURL(url: Bundle.main.bundleURL)?.absoluteString { + task.launchPath = "/usr/bin/open" + task.arguments = [appPath] + task.launch() + task.waitUntilExit() + } + } + + @objc func openGlobalChat() { + Task { @MainActor in + let serviceGUI = Service.shared.guiController + serviceGUI.openGlobalChat() + } + } + + func setupQuitOnUpdate() { + Task { + guard let url = Bundle.main.executableURL else { return } + let checker = await FileChangeChecker(fileURL: url) + + // If Xcode or Copilot for Xcode is made active, check if the executable of this program + // is changed. If changed, quit this program. + + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.isUserOfService + else { continue } + guard await checker.checkIfChanged() else { + Logger.service.info("Extension Service is not updated, no need to quit.") + continue + } + Logger.service.info("Extension Service will quit.") + #if DEBUG + #else + quit() + #endif + } + } + } + + func setupQuitOnUserTerminated() { + Task { + // Whenever Xcode or the host application quits, check if any of the two is running. + // If none, quit the XPC service. + + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didTerminateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard UserDefaults.shared.value(for: \.quitXPCServiceOnXcodeAndAppQuit) + else { continue } + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.isUserOfService + else { continue } + if NSWorkspace.shared.runningApplications.contains(where: \.isUserOfService) { + continue + } + quit() + } + } + } + + func setupQuitOnFeatureFlag() { + FeatureFlagNotifierImpl.shared.featureFlagsDidChange.sink { [weak self] (flags) in + if flags.x != true { + Logger.service.info("Xcode feature flag not granted, quitting.") + self?.quit() + } + }.store(in: &cancellables) + } + + func requestAccessoryAPIPermission() { + AXIsProcessTrustedWithOptions([ + kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, + ] as NSDictionary) + } + + @objc func checkForUpdate() { + updateChecker.checkForUpdates() + } + + func getXPCExtensionService() -> XPCExtensionService { + if let service = xpcExtensionService { return service } + let service = XPCExtensionService(logger: .service) + xpcExtensionService = service + return service + } +} + +extension NSRunningApplication { + var isUserOfService: Bool { + [ + "com.apple.dt.Xcode", + bundleIdentifierBase, + ].contains(bundleIdentifier) + } +} + +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 +} + diff --git a/ExtensionService/Assets.xcassets/AccentColor.colorset/Contents.json b/ExtensionService/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ExtensionService/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0a30d46 --- /dev/null +++ b/ExtensionService/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "CopilotforXcode-Icon@16w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "CopilotforXcode-Icon@16w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "CopilotforXcode-Icon@32w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "CopilotforXcode-Icon@32w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "CopilotforXcode-Icon@128w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "CopilotforXcode-Icon@128w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "CopilotforXcode-Icon@256w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "CopilotforXcode-Icon@256w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "CopilotforXcode-Icon@512w_1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "CopilotforXcode-Icon@512w_2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png new file mode 100644 index 0000000..3ee5242 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_1x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png new file mode 100644 index 0000000..88b20d1 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@128w_2x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png new file mode 100644 index 0000000..2bb554d Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_1x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png new file mode 100644 index 0000000..ce02bac Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@16w_2x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png new file mode 100644 index 0000000..7674f66 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_1x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png new file mode 100644 index 0000000..fc70596 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@256w_2x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png new file mode 100644 index 0000000..ce02bac Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_1x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png new file mode 100644 index 0000000..4d52c81 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@32w_2x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png new file mode 100644 index 0000000..fc70596 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_1x.png differ diff --git a/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png new file mode 100644 index 0000000..54da6e3 Binary files /dev/null and b/ExtensionService/Assets.xcassets/AppIcon.appiconset/CopilotforXcode-Icon@512w_2x.png differ diff --git a/ExtensionService/Assets.xcassets/Contents.json b/ExtensionService/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ExtensionService/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json new file mode 100644 index 0000000..60c5e84 --- /dev/null +++ b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "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" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png new file mode 100644 index 0000000..d6daae3 Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-16.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png new file mode 100644 index 0000000..07efd7f Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-32.png differ diff --git a/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png new file mode 100644 index 0000000..1e1cbac Binary files /dev/null and b/ExtensionService/Assets.xcassets/MenuBarIcon.imageset/copilot-48.png differ diff --git a/ExtensionService/AuthStatusChecker.swift b/ExtensionService/AuthStatusChecker.swift new file mode 100644 index 0000000..d144ed9 --- /dev/null +++ b/ExtensionService/AuthStatusChecker.swift @@ -0,0 +1,41 @@ +// +// AuthStatusChecker.swift +// ExtensionService +// +// Responsible for checking the logged in status of the user. +// + +import Foundation +import GitHubCopilotService + +class AuthStatusChecker { + var authService: GitHubCopilotAuthServiceType? + + public func updateStatusInBackground(notify: @escaping (_ status: String, _ isOk: Bool) -> Void) { + Task { + do { + let status = try await self.getCurrentAuthStatus() + DispatchQueue.main.async { + notify(status.description, status == .ok) + } + } catch { + DispatchQueue.main.async { + notify("\(error)", false) + } + } + } + } + + func getCurrentAuthStatus() async throws -> GitHubCopilotAccountStatus { + let service = try getAuthService() + let status = try await service.checkStatus() + return status + } + + func getAuthService() throws -> GitHubCopilotAuthServiceType { + if let service = authService { return service } + let service = try GitHubCopilotService() + authService = service + return service + } +} diff --git a/ExtensionService/ExtensionService.entitlements b/ExtensionService/ExtensionService.entitlements new file mode 100644 index 0000000..3c56897 --- /dev/null +++ b/ExtensionService/ExtensionService.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)group.$(BUNDLE_IDENTIFIER_BASE) + + com.apple.security.cs.disable-library-validation + + + diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist new file mode 100644 index 0000000..2ad095c --- /dev/null +++ b/ExtensionService/Info.plist @@ -0,0 +1,31 @@ + + + + + APPLICATION_SUPPORT_FOLDER + $(APPLICATION_SUPPORT_FOLDER) + APP_ID_PREFIX + $(AppIdentifierPrefix) + 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) + TEAM_ID_PREFIX + $(TeamIdentifierPrefix) + XPCService + + ServiceType + Application + + COPILOT_DOCS_URL + $(COPILOT_DOCS_URL) + COPILOT_FORUM_URL + $(COPILOT_FORUM_URL) + + diff --git a/ExtensionService/Main.storyboard b/ExtensionService/Main.storyboard new file mode 100644 index 0000000..5fc73e7 --- /dev/null +++ b/ExtensionService/Main.storyboard @@ -0,0 +1,684 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ExtensionService/ServiceDelegate.swift b/ExtensionService/ServiceDelegate.swift new file mode 100644 index 0000000..6280582 --- /dev/null +++ b/ExtensionService/ServiceDelegate.swift @@ -0,0 +1,20 @@ +import Foundation +import Service +import XPCShared + +class ServiceDelegate: NSObject, NSXPCListenerDelegate { + func listener( + _: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + newConnection.exportedInterface = NSXPCInterface( + with: XPCServiceProtocol.self + ) + + let exportedObject = XPCService() + newConnection.exportedObject = exportedObject + newConnection.resume() + return true + } +} + diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift new file mode 100644 index 0000000..5fdd444 --- /dev/null +++ b/ExtensionService/XPCController.swift @@ -0,0 +1,68 @@ +import Foundation +import Logger +import XPCShared + +final class XPCController: XPCServiceDelegate { + let bridge: XPCCommunicationBridge + let xpcListener: NSXPCListener + let xpcServiceDelegate: ServiceDelegate + + var pingTask: Task? + + init() { + let bridge = XPCCommunicationBridge(logger: .client) + let listener = NSXPCListener.anonymous() + let delegate = ServiceDelegate() + listener.delegate = delegate + listener.resume() + xpcListener = listener + xpcServiceDelegate = delegate + self.bridge = bridge + + Task { + bridge.setDelegate(self) + createPingTask() + } + } + + func quit() async { + bridge.setDelegate(nil) + pingTask?.cancel() + try? await bridge.quit() + } + + deinit { + xpcListener.invalidate() + pingTask?.cancel() + } + + func createPingTask() { + pingTask?.cancel() + pingTask = Task { [weak self] in + 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) + } catch { + try await Task.sleep(nanoseconds: 1_000_000_000) + #if DEBUG + // No log, but you should run CommunicationBridge, too. + #else + Logger.service + .error("Failed to connect to bridge: \(error.localizedDescription)") + #endif + } + } + } + } + + func connectionDidInvalidate() async { + // ignore + } + + func connectionDidInterrupt() async { + createPingTask() // restart the ping task so that it can bring the bridge back immediately. + } +} + diff --git a/Helper/ReloadLaunchAgent.swift b/Helper/ReloadLaunchAgent.swift new file mode 100644 index 0000000..99c934b --- /dev/null +++ b/Helper/ReloadLaunchAgent.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation + +struct ReloadLaunchAgent: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Reload the launch agent" + ) + + @Option(name: .long, help: "The service identifier of the service.") + var serviceIdentifier: String + + var launchAgentDirURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents") + } + + var launchAgentPath: String { + launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path + } + + func run() throws { + try? launchctl("unload", launchAgentPath) + try launchctl("load", launchAgentPath) + } +} + +private func launchctl(_ args: String...) throws { + return try process("/bin/launchctl", args) +} + +private func process(_ launchPath: String, _ args: [String]) throws { + let task = Process() + task.launchPath = launchPath + task.arguments = args + task.environment = [ + "PATH": "/usr/bin", + ] + let outpipe = Pipe() + task.standardOutput = outpipe + try task.run() + task.waitUntilExit() + + struct E: Error, LocalizedError { + var errorDescription: String? + } + + if task.terminationStatus == 0 { + return + } + throw E( + errorDescription: "Failed to restart. Please make sure the launch agent is already loaded." + ) +} diff --git a/Helper/main.swift b/Helper/main.swift new file mode 100644 index 0000000..ef9f262 --- /dev/null +++ b/Helper/main.swift @@ -0,0 +1,14 @@ +import ArgumentParser +import Foundation + +struct Helper: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "helper", + abstract: "Helper CLI for Copilot for Xcode", + subcommands: [ + ReloadLaunchAgent.self, + ] + ) +} + +Helper.main() diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..163ff11 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 GitHub + +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. diff --git a/PackageAssets/DSStore.template b/PackageAssets/DSStore.template new file mode 100644 index 0000000..18678db Binary files /dev/null and b/PackageAssets/DSStore.template differ diff --git a/PackageAssets/background.png b/PackageAssets/background.png new file mode 100644 index 0000000..84feaf0 Binary files /dev/null and b/PackageAssets/background.png differ diff --git a/README.md b/README.md index 46ae9d1..6e0e34f 100644 --- a/README.md +++ b/README.md @@ -1 +1,70 @@ -# GitHub Copilot For Xcode +# GitHub Copilot For Xcode + +Demo of GitHub Copilot for Xcode + +GitHub Copilot for Xcode is macOS application and Xcode extension that enables +using GitHub Copilot code completions in Xcode. + +## Beta Preview Policy + +As per [GitHub's Terms of Service](https://docs.github.com/en/github/site-policy/github-terms-of-service#j-beta-previews) we want to remind you that: + +> 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. + + +## Requirements + +- macOS 12+ +- Xcode 8+ +- A GitHub Copilot subscription. To learn more, visit [https://github.com/features/copilot](https://github.com/features/copilot). + +## Getting Started + +1. Download the latest `dmg` from: https://github.com/github/copilot-xcode/releases/latest/download/GitHubCopilotForXcode.dmg + Updates can be downloaded and installed by the app. + +1. Open the `dmg` and drag the `GitHub Copilot for Xcode.app` into the `Applications` folder. +
+ Screenshot of opened dmg + +1. On the first opening the application it will warn that it was downloaded from the internet. Click `Open` to proceed. +
+ Screenshot of downloaded from the internet warning + +1. A background item will be added for the application to be able to start itself when Xcode starts. +
+ Screenshot of background item + +1. Two important permissions are required for the application to operate well: `Accessibility` and `Xcode Source Editor Extension`. The first time the application is run these permissions should be requested. You may need to click `Refresh` in the settings if not prompted. +
+ Screenshot of accessibility permission request + Screenshot of accessibility permission + Screenshot of extension permission + +1. After granting the extension permission, please restart Xcode so the `Github Copilot` menu is available under the Xcode `Editor` menu. +
+
+ Screenshot of Xcode Editor GitHub Copilot menu item + +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 + +1. To install updates, click `Check for Updates` from the menu item or in the settings application. After installing a new version, Xcode must be restarted to use the new version correctly. New versions can also be installed from `dmg` files downloaded from the releases page. When installing a new version via `dmg`, the application must be run manually the first time to accept the downloaded from the internet warning. +
+ Screenshot of update message + +## License + +This project is licensed under the terms of the MIT open source license. Please +refer to [MIT](./LICENSE.txt) for the full terms. + +## Support + +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). + +## Acknowledgement + +Thank you to @intitni for creating the original that this project is based on. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4279c87 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +Thanks for helping make GitHub safe for everyone. + +# Security + +GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. + +## Reporting Security Issues + +If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please send an email to opensource-security[@]github.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + + * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy + +See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) \ No newline at end of file diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..3376205 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,20 @@ +# Support + +## How to get help + +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). + +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 +community questions in a timely manner. + +## GitHub Support Policy + +GitHub Copilot for Xcode is considered a Beta Preview under the [GitHub Terms of +Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#j-beta-previews). + +Once GitHub Copilot for Xcode is generally available, it will be subject to the +[GitHub Additional Product +Terms](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features). diff --git a/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json b/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json b/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/Assets.xcassets/Contents.json b/SandboxedClientTester/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SandboxedClientTester/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/ContentView.swift b/SandboxedClientTester/ContentView.swift new file mode 100644 index 0000000..b56ddd0 --- /dev/null +++ b/SandboxedClientTester/ContentView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import Client + +struct ContentView: View { + @State var text: String = "Hello, world!" + var body: some View { + VStack { + Button(action: { + Task { + do { + let service = try getService() + let version = try await service.getXPCServiceVersion() + text = "Version: \(version.version) Build: \(version.build)" + } catch { + text = error.localizedDescription + } + } + }) { + Text("Test") + } + Text(text) + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/SandboxedClientTester/Info.plist b/SandboxedClientTester/Info.plist new file mode 100644 index 0000000..cb7f95c --- /dev/null +++ b/SandboxedClientTester/Info.plist @@ -0,0 +1,8 @@ + + + + + BUNDLE_IDENTIFIER_BASE + $(BUNDLE_IDENTIFIER_BASE) + + diff --git a/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json b/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SandboxedClientTester/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SandboxedClientTester/SandboxedClientTester.entitlements b/SandboxedClientTester/SandboxedClientTester.entitlements new file mode 100644 index 0000000..9e6f319 --- /dev/null +++ b/SandboxedClientTester/SandboxedClientTester.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(BUNDLE_IDENTIFIER_BASE).CommunicationBridge + + + diff --git a/SandboxedClientTester/SandboxedClientTesterApp.swift b/SandboxedClientTester/SandboxedClientTesterApp.swift new file mode 100644 index 0000000..ef03ae5 --- /dev/null +++ b/SandboxedClientTester/SandboxedClientTesterApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct SandboxedClientTesterApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Script/MakeDSStore.py b/Script/MakeDSStore.py new file mode 100644 index 0000000..a257185 --- /dev/null +++ b/Script/MakeDSStore.py @@ -0,0 +1,56 @@ +# Run MakeDSStore.sh rather than use this script directly. +import struct +from ds_store import DSStore +from mac_alias import Alias + +# See https://github.com/gitpan/Mac-Finder-DSStore/blob/master/DSStoreFormat.pod + +with DSStore.open('/Volumes/GitHub Copilot for Xcode/DSStore.template', 'w+') as ds: + # finder window coordinates (top, left, bottom, right) + # icnv indicates icon view, followed by four unknown bytes + fwi0 = struct.pack('>H', 100) + \ + struct.pack('>H', 200) + \ + struct.pack('>H', 400) + \ + struct.pack('>H', 600) + \ + bytes('icnv', 'ascii') + bytearray([0] * 4) + ds['.']['fwi0'] = ('blob', fwi0) + + # location of the app icon + ds['GitHub Copilot for Xcode.app']['Iloc'] = (100, 150) + # location of the Applications folder + ds['Applications']['Iloc'] = (300, 150) + + # hidden files outside the window + ds['.DS_Store']['Iloc'] = (650, 175) + ds['.background']['Iloc'] = (700, 175) + + # a plist with settings for the icon view + icvp = { + 'viewOptionsVersion': 1, + 'gridOffsetX': 0, + 'gridOffsetY': 0, + 'gridSpacing': 100, + 'iconSize': 128, + 'textSize': 12, + 'showIconPreview': True, + 'showItemInfo': False, + 'labelOnBottom': True, + 'scrollPositionX': 0, + 'scrollPositionY': 0, + 'arrangeBy': 'none', + 'backgroundColorRed': 1.0, + 'backgroundColorGreen': 1.0, + 'backgroundColorBlue': 1.0, + 'backgroundType': 2, + 'backgroundImageAlias': Alias.for_file('/Volumes/GitHub Copilot for Xcode/.background/background.png').to_bytes(), + } + ds['.']['icvp'] = icvp + + # window sidebar width + ds['.']['fwsw'] = ('long', 0) + # window height + ds['.']['fwvh'] = ('shor', 300) + # unknown meaning + ds['.']['ICVO'] = ('bool', True) + # text size + ds['.']['icvt'] = ('shor', 12) diff --git a/Script/MakeDSStore.sh b/Script/MakeDSStore.sh new file mode 100755 index 0000000..7e42e44 --- /dev/null +++ b/Script/MakeDSStore.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -e + +# Ensure we're in the root of the repo +cd "$(dirname "$0")/.." + +# Must have python3 installed +if ! command -v python3 &> /dev/null +then + echo "python3 could not be found. Install phyton3 and try again." + exit 1 +fi + +# We need a volume with the background image in order to create the correct alias for it +mkdir -p build/image/.background +cp PackageAssets/background.png build/image/.background +hdiutil create -volname "GitHub Copilot for Xcode" -srcfolder build/image -format UDRW build/GitHubCopilotforXcode.dmg +hdiutil attach -readwrite build/GitHubCopilotforXcode.dmg + + +# Create a python virtual environment +mkdir -p build/venv +python3 -m venv build/venv + +# Install ds-store +./build/venv/bin/pip install ds-store mac-alias==2.2.0 ds-store==1.3.0 +./build/venv/bin/python Script/MakeDSStore.py + +# Run it +./build/venv/bin/python Script/MakeDSStore.py + +# Save the created .DS_Store file +cp '/Volumes/GitHub Copilot for Xcode/DSStore.template' PackageAssets/DSStore.template + +# Clean up +hdiutil detach '/Volumes/GitHub Copilot for Xcode' +rm -rf build/GitHubCopilotforXcode.dmg +rm -rf build/image +rm -rf build/venv diff --git a/Script/next-version.sh b/Script/next-version.sh new file mode 100755 index 0000000..07864c6 --- /dev/null +++ b/Script/next-version.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Returns the next version number for a release. +# +# Can be run four different ways. +# +# Return the next major version number, e.g. 1.0.0: +# ./next-version.sh -m +# +# Return the next minor version number (switch is optional), e.g. 0.1.0: +# ./next-version.sh +# ./next-version.sh -n +# +# Return the next version number with a supplied patch value, e.g. 0.0.123: +# ./next-version.sh -p 123 +# +# Return the current version number: +# ./next-version.sh -c + +set -e + +# Ensure we're in the root of the repo so that gh works as expected +cd "$(dirname "$0")/.." + +LATEST_VERSION=$(gh release list --exclude-drafts --exclude-pre-releases --json tagName --limit 1 | jq -r '.[0].tagName') +if [ "$LATEST_VERSION" == "null" ]; then + # this will be the first release + LATEST_VERSION="0.0.0" +fi + +if [ "$1" == "-c" ]; then + echo $LATEST_VERSION + exit 0 +fi + +MAJOR_VERSION=$(echo "$LATEST_VERSION" | sed -En 's/^([0-9]+)\.[0-9]+\.[0-9]+$/\1/p') +MINOR_VERSION=$(echo "$LATEST_VERSION" | sed -En 's/^[0-9]+\.([0-9]+)\.[0-9]+$/\1/p') + +if [ "$1" == "-m" ]; then + echo $((MAJOR_VERSION + 1)).0.0 + exit 0 +fi +if [ "$1" == "-n" ] || [ -z "$1" ]; then + echo $MAJOR_VERSION.$((MINOR_VERSION + 1)).0 + exit 0 +fi +if [ "$1" == "-p" ] && [ ! -z "$2" ]; then + echo $MAJOR_VERSION.$MINOR_VERSION.$2 + exit 0 +fi + +echo "Usage: $0 [-c | -m | -n | -p ]" 1>&2 +echo " -c Return the current version number" 1>&2 +echo " -m Return the next major version number" 1>&2 +echo " -n Return the next minor version number (the default)" 1>&2 +echo " -p Return the next version number using the supplied patch number" 1>&2 +exit 1 diff --git a/Script/uninstall-app.sh b/Script/uninstall-app.sh new file mode 100755 index 0000000..f029fe9 --- /dev/null +++ b/Script/uninstall-app.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Uninstall the application and remove the settings and permissions +# +# Usage: ./uninstall-app.sh + +# Remove the settings and permissions (should happen before removing the app) +tccutil reset All com.github.CopilotForXcode +tccutil reset All com.github.CopilotForXcode.ExtensionService + +# Remove dev versions as well +tccutil reset All dev.com.github.CopilotForXcode +tccutil reset All dev.com.github.CopilotForXcode.ExtensionService + +# Remove launch agent +launchctl remove com.github.CopilotForXcode.CommunicationBridge +launchctl remove dev.com.github.CopilotForXcode.CommunicationBridge + +# Remove app +rm -rf /Applications/Copilot\ for\ Xcode.app +rm -rf /Applications/GitHub\ Copilot\ for\ Xcode.app + +# Remove user preferences +rm -f ~/Library/Preferences/com.github.CopilotForXcode.plist +rm -f ~/Library/Preferences/com.github.CopilotForXcode.ExtensionService.plist +rm -f ~/Library/Preferences/dev.com.github.CopilotForXcode.plist +rm -f ~/Library/Preferences/dev.com.github.CopilotForXcode.ExtensionService.plist + +echo 'Finished' + diff --git a/Server/package-lock.json b/Server/package-lock.json new file mode 100644 index 0000000..cb7f367 --- /dev/null +++ b/Server/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "@github/copilot-xcode", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@github/copilot-xcode", + "version": "0.0.1", + "dependencies": { + "@github/copilot-language-server": "^1.232.0" + } + }, + "node_modules/@github/copilot-language-server": { + "version": "1.232.0", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.232.0.tgz", + "integrity": "sha512-m+CCOjpRNPRRixgFBurE7vsDmHVpCHisz+R2/V9bLwuQJ891/1LzbtjhA/162tRBLw3QmOWPSPheFLdp8D6axQ==", + "bin": { + "copilot-language-server": "dist/language-server.js" + } + } + } +} diff --git a/Server/package.json b/Server/package.json new file mode 100644 index 0000000..bb7340d --- /dev/null +++ b/Server/package.json @@ -0,0 +1,9 @@ +{ + "name": "@github/copilot-xcode", + "version": "0.0.1", + "description": "Package for downloading @github/copilot-language-server", + "private": true, + "dependencies": { + "@github/copilot-language-server": "^1.232.0" + } +} diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan new file mode 100644 index 0000000..cd25c46 --- /dev/null +++ b/TestPlan.xctestplan @@ -0,0 +1,97 @@ +{ + "configurations" : [ + { + "id" : "586480F5-DC84-425D-814F-7A5F569A1974", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "environmentVariableEntries" : [ + { + "key" : "IS_UNIT_TEST", + "value" : "YES" + }, + { + "key" : "SUEnableAutomaticChecks", + "value" : "NO" + } + ], + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "ServiceTests", + "name" : "ServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "SuggestionInjectorTests", + "name" : "SuggestionInjectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "SuggestionWidgetTests", + "name" : "SuggestionWidgetTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionBasicTests", + "name" : "SuggestionBasicTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SharedUIComponentsTests", + "name" : "SharedUIComponentsTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "GitHubCopilotServiceTests", + "name" : "GitHubCopilotServiceTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "SuggestionProviderTests", + "name" : "SuggestionProviderTests" + } + }, + { + "target" : { + "containerPath" : "container:Core", + "identifier" : "KeyBindingManagerTests", + "name" : "KeyBindingManagerTests" + } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "WorkspaceSuggestionServiceTests", + "name" : "WorkspaceSuggestionServiceTests" + } + } + ], + "version" : 1 +} diff --git a/Tool/Package.swift b/Tool/Package.swift new file mode 100644 index 0000000..4051f8b --- /dev/null +++ b/Tool/Package.swift @@ -0,0 +1,272 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Tool", + platforms: [.macOS(.v12)], + products: [ + .library(name: "XPCShared", targets: ["XPCShared"]), + .library(name: "Terminal", targets: ["Terminal"]), + .library(name: "Preferences", targets: ["Preferences", "Configs"]), + .library(name: "Logger", targets: ["Logger"]), + .library(name: "ChatAPIService", targets: ["ChatAPIService"]), + .library(name: "ChatTab", targets: ["ChatTab"]), + .library(name: "FileSystem", targets: ["FileSystem"]), + .library(name: "SuggestionBasic", targets: ["SuggestionBasic"]), + .library(name: "Toast", targets: ["Toast"]), + .library(name: "SharedUIComponents", targets: ["SharedUIComponents"]), + .library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]), + .library(name: "Workspace", targets: ["Workspace", "WorkspaceSuggestionService"]), + .library( + name: "SuggestionProvider", + targets: ["SuggestionProvider"] + ), + .library( + name: "ConversationServiceProvider", + targets: ["ConversationServiceProvider"] + ), + .library( + name: "GitHubCopilotService", + targets: ["GitHubCopilotService"] + ), + .library( + name: "BuiltinExtension", + targets: ["BuiltinExtension"] + ), + .library( + name: "AppMonitoring", + targets: [ + "XcodeInspector", + "ActiveApplicationMonitor", + "AXExtension", + "AXNotificationStream", + "AppActivator", + ] + ), + .library(name: "DebounceFunction", targets: ["DebounceFunction"]), + .library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]), + .library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]), + ], + 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/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/devm33/Highlightr", branch: "master"), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture", + from: "1.10.4" + ), + .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") + ], + targets: [ + // MARK: - Helpers + + .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]), + + .target(name: "Configs"), + + .target(name: "Preferences", dependencies: ["Configs"]), + + .target(name: "Terminal"), + + .target(name: "Logger"), + + .target(name: "FileSystem"), + + .target( + name: "CustomAsyncAlgorithms", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + + .target( + name: "Toast", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + + .target(name: "DebounceFunction"), + + .target( + name: "AppActivator", + dependencies: [ + "XcodeInspector", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + + .target(name: "ActiveApplicationMonitor"), + + .target( + name: "SuggestionBasic", + dependencies: [ + "LanguageClient", + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + + .testTarget( + name: "SuggestionBasicTests", + dependencies: ["SuggestionBasic"] + ), + + .target(name: "AXExtension"), + + .target( + name: "AXNotificationStream", + dependencies: [ + "Preferences", + "Logger", + ] + ), + + .target( + name: "XcodeInspector", + dependencies: [ + "AXExtension", + "SuggestionBasic", + "AXNotificationStream", + "Logger", + "Toast", + "Preferences", + "AsyncPassthroughSubject", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ] + ), + + .testTarget(name: "XcodeInspectorTests", dependencies: ["XcodeInspector"]), + + .target(name: "UserDefaultsObserver"), + + .target(name: "AsyncPassthroughSubject"), + + .target( + name: "BuiltinExtension", + dependencies: [ + "SuggestionBasic", + "SuggestionProvider", + "Workspace", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + + .target( + name: "SharedUIComponents", + dependencies: [ + "Highlightr", + "Preferences", + "SuggestionBasic", + "DebounceFunction", + "ConversationServiceProvider", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "SharedUIComponentsTests", dependencies: ["SharedUIComponents"]), + + .target( + name: "Workspace", + dependencies: [ + "UserDefaultsObserver", + "SuggestionBasic", + "Logger", + "Preferences", + "XcodeInspector", + ] + ), + + .target( + name: "WorkspaceSuggestionService", + dependencies: [ + "Workspace", + "SuggestionProvider", + "XPCShared", + "BuiltinExtension", + "GitHubCopilotService", + ] + ), + + .testTarget( + name: "WorkspaceSuggestionServiceTests", + dependencies: [ + "ConversationServiceProvider", + "WorkspaceSuggestionService" + ] + ), + + // MARK: - Services + + .target(name: "SuggestionProvider", dependencies: [ + "SuggestionBasic", + "UserDefaultsObserver", + "Preferences", + "Logger", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ]), + .testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]), + + .target(name: "ConversationServiceProvider", dependencies: [ + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ]), + + + // MARK: - GitHub Copilot + + .target( + name: "GitHubCopilotService", + dependencies: [ + "LanguageClient", + "SuggestionBasic", + "Logger", + "Preferences", + "Terminal", + "BuiltinExtension", + "ConversationServiceProvider", + .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"), + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + ] + ), + .testTarget( + name: "GitHubCopilotServiceTests", + dependencies: ["GitHubCopilotService", + "ConversationServiceProvider"] + ), + + // MARK: - ChatAPI + + .target( + name: "ChatAPIService", + dependencies: [ + "Logger", + "Preferences", + .product(name: "JSONRPC", package: "JSONRPC"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] + ), + + // MARK: - UI + + .target( + name: "ChatTab", + dependencies: [.product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + )] + ), + ] +) + diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift new file mode 100644 index 0000000..274e38f --- /dev/null +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -0,0 +1,268 @@ +import AppKit +import Foundation + +// MARK: - State + +public extension AXUIElement { + /// Set global timeout in seconds. + static func setGlobalMessagingTimeout(_ timeout: Float) { + AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), timeout) + } + + /// Set timeout in seconds for this element. + func setMessagingTimeout(_ timeout: Float) { + AXUIElementSetMessagingTimeout(self, timeout) + } + + var identifier: String { + (try? copyValue(key: kAXIdentifierAttribute)) ?? "" + } + + var value: String { + (try? copyValue(key: kAXValueAttribute)) ?? "" + } + + var intValue: Int? { + (try? copyValue(key: kAXValueAttribute)) + } + + var title: String { + (try? copyValue(key: kAXTitleAttribute)) ?? "" + } + + var role: String { + (try? copyValue(key: kAXRoleAttribute)) ?? "" + } + + var doubleValue: Double { + (try? copyValue(key: kAXValueAttribute)) ?? 0.0 + } + + var document: String? { + try? copyValue(key: kAXDocumentAttribute) + } + + /// Label in Accessibility Inspector. + var description: String { + (try? copyValue(key: kAXDescriptionAttribute)) ?? "" + } + + /// Type in Accessibility Inspector. + var roleDescription: String { + (try? copyValue(key: kAXRoleDescriptionAttribute)) ?? "" + } + + var label: String { + (try? copyValue(key: kAXLabelValueAttribute)) ?? "" + } + + var isSourceEditor: Bool { + description == "Source Editor" + } + + var selectedTextRange: ClosedRange? { + guard let value: AXValue = try? copyValue(key: kAXSelectedTextRangeAttribute) + else { return nil } + var range: CFRange = .init(location: 0, length: 0) + if AXValueGetValue(value, .cfRange, &range) { + return range.location...(range.location + range.length) + } + return nil + } + + var isFocused: Bool { + (try? copyValue(key: kAXFocusedAttribute)) ?? false + } + + var isEnabled: Bool { + (try? copyValue(key: kAXEnabledAttribute)) ?? false + } + + var isHidden: Bool { + (try? copyValue(key: kAXHiddenAttribute)) ?? false + } +} + +// MARK: - Rect + +public extension AXUIElement { + var position: CGPoint? { + guard let value: AXValue = try? copyValue(key: kAXPositionAttribute) + else { return nil } + var point: CGPoint = .zero + if AXValueGetValue(value, .cgPoint, &point) { + return point + } + return nil + } + + var size: CGSize? { + guard let value: AXValue = try? copyValue(key: kAXSizeAttribute) + else { return nil } + var size: CGSize = .zero + if AXValueGetValue(value, .cgSize, &size) { + return size + } + return nil + } + + var rect: CGRect? { + guard let position, let size else { return nil } + return .init(origin: position, size: size) + } +} + +// MARK: - Relationship + +public extension AXUIElement { + var focusedElement: AXUIElement? { + try? copyValue(key: kAXFocusedUIElementAttribute) + } + + var sharedFocusElements: [AXUIElement] { + (try? copyValue(key: kAXChildrenAttribute)) ?? [] + } + + var window: AXUIElement? { + try? copyValue(key: kAXWindowAttribute) + } + + var windows: [AXUIElement] { + (try? copyValue(key: kAXWindowsAttribute)) ?? [] + } + + var isFullScreen: Bool { + (try? copyValue(key: "AXFullScreen")) ?? false + } + + var focusedWindow: AXUIElement? { + try? copyValue(key: kAXFocusedWindowAttribute) + } + + var topLevelElement: AXUIElement? { + try? copyValue(key: kAXTopLevelUIElementAttribute) + } + + var rows: [AXUIElement] { + (try? copyValue(key: kAXRowsAttribute)) ?? [] + } + + var parent: AXUIElement? { + try? copyValue(key: kAXParentAttribute) + } + + var children: [AXUIElement] { + (try? copyValue(key: kAXChildrenAttribute)) ?? [] + } + + var menuBar: AXUIElement? { + try? copyValue(key: kAXMenuBarAttribute) + } + + var visibleChildren: [AXUIElement] { + (try? copyValue(key: kAXVisibleChildrenAttribute)) ?? [] + } + + func child( + identifier: String? = nil, + title: String? = nil, + role: String? = nil + ) -> AXUIElement? { + for child in children { + let match = { + if let identifier, child.identifier != identifier { return false } + if let title, child.title != title { return false } + if let role, child.role != role { return false } + return true + }() + if match { return child } + } + for child in children { + if let target = child.child( + identifier: identifier, + title: title, + role: role + ) { return target } + } + return nil + } + + func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] { + var all = [AXUIElement]() + for child in children { + if match(child) { all.append(child) } + } + for child in children { + all.append(contentsOf: child.children(where: match)) + } + return all + } + + func firstParent(where match: (AXUIElement) -> Bool) -> AXUIElement? { + guard let parent = parent else { return nil } + if match(parent) { return parent } + return parent.firstParent(where: match) + } + + func firstChild(where match: (AXUIElement) -> Bool) -> AXUIElement? { + for child in children { + if match(child) { return child } + } + for child in children { + if let target = child.firstChild(where: match) { + return target + } + } + return nil + } + + func visibleChild(identifier: String) -> AXUIElement? { + for child in visibleChildren { + if child.identifier == identifier { return child } + if let target = child.visibleChild(identifier: identifier) { return target } + } + return nil + } + + var verticalScrollBar: AXUIElement? { + try? copyValue(key: kAXVerticalScrollBarAttribute) + } +} + +// MARK: - Helper + +public extension AXUIElement { + func copyValue(key: String, ofType _: T.Type = T.self) throws -> T { + var value: AnyObject? + let error = AXUIElementCopyAttributeValue(self, key as CFString, &value) + if error == .success, let value = value as? T { + return value + } + throw error + } + + func copyParameterizedValue( + key: String, + parameters: AnyObject, + ofType _: T.Type = T.self + ) throws -> T { + var value: AnyObject? + let error = AXUIElementCopyParameterizedAttributeValue( + self, + key as CFString, + parameters as CFTypeRef, + &value + ) + if error == .success, let value = value as? T { + return value + } + throw error + } +} + +#if hasFeature(RetroactiveAttribute) +extension AXError: @retroactive Error {} +#else +extension AXError: Error {} +#endif + diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift new file mode 100644 index 0000000..89fca01 --- /dev/null +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -0,0 +1,163 @@ +import AppKit +import ApplicationServices +import Foundation +import Logger +import Preferences + +public final class AXNotificationStream: AsyncSequence { + public typealias Stream = AsyncStream + public typealias Continuation = Stream.Continuation + public typealias AsyncIterator = Stream.AsyncIterator + public typealias Element = (name: String, element: AXUIElement, info: CFDictionary) + + private var continuation: Continuation + private let stream: Stream + + private let file: StaticString + private let line: UInt + private let function: StaticString + + public func makeAsyncIterator() -> Stream.AsyncIterator { + stream.makeAsyncIterator() + } + + deinit { + continuation.finish() + } + + public convenience init( + app: NSRunningApplication, + element: AXUIElement? = nil, + notificationNames: String..., + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + self.init( + app: app, + element: element, + notificationNames: notificationNames, + file: file, + line: line, + function: function + ) + } + + public init( + app: NSRunningApplication, + element: AXUIElement? = nil, + notificationNames: [String], + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + self.file = file + self.line = line + self.function = function + + let mode: CFRunLoopMode = UserDefaults.shared + .value(for: \.observeToAXNotificationWithDefaultMode) ? .defaultMode : .commonModes + + let runLoop: CFRunLoop = CFRunLoopGetMain() + + var cont: Continuation! + stream = Stream { continuation in + cont = continuation + } + continuation = cont + var observer: AXObserver? + + func callback( + observer: AXObserver, + element: AXUIElement, + notificationName: CFString, + userInfo: CFDictionary, + pointer: UnsafeMutableRawPointer? + ) { + guard let pointer = pointer?.assumingMemoryBound(to: Continuation.self) + else { return } + pointer.pointee.yield((notificationName as String, element, userInfo)) + } + + _ = AXObserverCreateWithInfoCallback( + app.processIdentifier, + callback, + &observer + ) + guard let observer else { + continuation.finish() + return + } + + let observingElement = element ?? AXUIElementCreateApplication(app.processIdentifier) + continuation.onTermination = { @Sendable _ in + for name in notificationNames { + AXObserverRemoveNotification(observer, observingElement, name as CFString) + } + CFRunLoopRemoveSource( + runLoop, + AXObserverGetRunLoopSource(observer), + mode + ) + } + + Task { @MainActor [weak self] in + CFRunLoopAddSource( + runLoop, + AXObserverGetRunLoopSource(observer), + mode + ) + var pendingRegistrationNames = Set(notificationNames) + var retry = 0 + while !pendingRegistrationNames.isEmpty, retry < 100 { + guard let self else { return } + retry += 1 + for name in notificationNames { + await Task.yield() + let e = withUnsafeMutablePointer(to: &self.continuation) { pointer in + AXObserverAddNotification( + observer, + observingElement, + name as CFString, + pointer + ) + } + switch e { + case .success: + pendingRegistrationNames.remove(name) + case .actionUnsupported: + Logger.service.error("AXObserver: Action unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .apiDisabled: + Logger.service + .error("AXObserver: Accessibility API disabled, will try again later") + retry -= 1 + case .invalidUIElement: + Logger.service + .error("AXObserver: Invalid UI element, notification name \(name)") + pendingRegistrationNames.remove(name) + case .invalidUIElementObserver: + Logger.service.error("AXObserver: Invalid UI element observer") + pendingRegistrationNames.remove(name) + case .cannotComplete: + Logger.service + .error("AXObserver: Failed to observe \(name), will try again later") + case .notificationUnsupported: + Logger.service.error("AXObserver: Notification unsupported: \(name)") + pendingRegistrationNames.remove(name) + case .notificationAlreadyRegistered: + Logger.service.info("AXObserver: Notification already registered: \(name)") + pendingRegistrationNames.remove(name) + default: + Logger.service + .error( + "AXObserver: Unrecognized error \(e) when registering \(name), will try again later" + ) + } + } + try await Task.sleep(nanoseconds: 1_500_000_000) + } + } + } +} + diff --git a/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift new file mode 100644 index 0000000..12c309e --- /dev/null +++ b/Tool/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift @@ -0,0 +1,115 @@ +import AppKit + +public struct RunningApplicationInfo: Sendable { + public let isXcode: Bool + public let isActive: Bool + public let isHidden: Bool + public let localizedName: String? + public let bundleIdentifier: String? + public let bundleURL: URL? + public let executableURL: URL? + public let processIdentifier: pid_t + public let launchDate: Date? + public let executableArchitecture: Int + + init(_ application: NSRunningApplication) { + isXcode = application.isXcode + isActive = application.isActive + isHidden = application.isHidden + localizedName = application.localizedName + bundleIdentifier = application.bundleIdentifier + bundleURL = application.bundleURL + executableURL = application.executableURL + processIdentifier = application.processIdentifier + launchDate = application.launchDate + executableArchitecture = application.executableArchitecture + } +} + +public extension NSRunningApplication { + var info: RunningApplicationInfo { RunningApplicationInfo(self) } +} + +public final class ActiveApplicationMonitor { + public static let shared = ActiveApplicationMonitor() + public private(set) var latestXcode: NSRunningApplication? = NSWorkspace.shared + .runningApplications + .first(where: \.isXcode) + public private(set) var previousApp: NSRunningApplication? + public private(set) var activeApplication = NSWorkspace.shared.runningApplications + .first(where: \.isActive) + { + didSet { + if activeApplication?.isXcode ?? false { + latestXcode = activeApplication + } + previousApp = oldValue + } + } + + private var infoContinuations: [UUID: AsyncStream.Continuation] = [:] + + private init() { + activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) + + Task { + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in sequence { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + activeApplication = app + notifyContinuations() + } + } + } + + deinit { + for continuation in infoContinuations { + continuation.value.finish() + } + } + + public var activeXcode: NSRunningApplication? { + if activeApplication?.isXcode ?? false { + return activeApplication + } + return nil + } + + public func createInfoStream() -> AsyncStream { + .init { continuation in + let id = UUID() + Task { @MainActor in + continuation.onTermination = { _ in + self.removeInfoContinuation(id: id) + } + addInfoContinuation(continuation, id: id) + continuation.yield(activeApplication?.info) + } + } + } + + func addInfoContinuation( + _ continuation: AsyncStream.Continuation, + id: UUID + ) { + infoContinuations[id] = continuation + } + + func removeInfoContinuation(id: UUID) { + infoContinuations[id] = nil + } + + private func notifyContinuations() { + for continuation in infoContinuations { + continuation.value.yield(activeApplication?.info) + } + } +} + +public extension NSRunningApplication { + var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" } +} + diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift new file mode 100644 index 0000000..b50f3bf --- /dev/null +++ b/Tool/Sources/AppActivator/AppActivator.swift @@ -0,0 +1,107 @@ +import AppKit +import Dependencies +import XcodeInspector + +public extension NSWorkspace { + static func activateThisApp(delay: TimeInterval = 0.3) { + Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + // NSApp.activate may fail. And since macOS 14, it looks like the app needs other + // apps to call `yieldActivationToApplication` to activate itself? + + let activated = NSRunningApplication.current + .activate(options: [.activateIgnoringOtherApps]) + + if activated { return } + + // Fallback solution + + let appleScript = """ + tell application "System Events" + set frontmost of the first process whose unix id is \ + \(ProcessInfo.processInfo.processIdentifier) to true + end tell + """ + try await runAppleScript(appleScript) + } + } + + static func activatePreviousActiveApp(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = await XcodeInspector.shared.safe.previousActiveApplication + else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + _ = app.activate() + } + } + + static func activatePreviousActiveXcode(delay: TimeInterval = 0.2) { + Task { @MainActor in + guard let app = await XcodeInspector.shared.safe.latestActiveXcode else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + _ = app.activate() + } + } +} + +struct ActivateThisAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activateThisApp() } +} + +struct ActivatePreviousActiveAppDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveApp() } +} + +struct ActivatePreviousActiveXcodeDependencyKey: DependencyKey { + static var liveValue: () -> Void = { NSWorkspace.activatePreviousActiveXcode() } +} + +public extension DependencyValues { + var activateThisApp: () -> Void { + get { self[ActivateThisAppDependencyKey.self] } + set { self[ActivateThisAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveApp: () -> Void { + get { self[ActivatePreviousActiveAppDependencyKey.self] } + set { self[ActivatePreviousActiveAppDependencyKey.self] = newValue } + } + + var activatePreviousActiveXcode: () -> Void { + get { self[ActivatePreviousActiveXcodeDependencyKey.self] } + set { self[ActivatePreviousActiveXcodeDependencyKey.self] = newValue } + } +} + +@discardableResult +func runAppleScript(_ appleScript: String) async throws -> String { + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", appleScript] + let outpipe = Pipe() + task.standardOutput = outpipe + task.standardError = Pipe() + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { _ in + do { + if let data = try outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(returning: content) + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + diff --git a/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift b/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift new file mode 100644 index 0000000..94d033d --- /dev/null +++ b/Tool/Sources/AsyncPassthroughSubject/AsyncPassthroughSubject.swift @@ -0,0 +1,54 @@ +import AppKit +import Foundation + +public actor AsyncPassthroughSubject { + var tasks: [AsyncStream.Continuation] = [] + + deinit { + tasks.forEach { $0.finish() } + } + + public init() {} + + public func notifications() -> AsyncStream { + AsyncStream { [weak self] continuation in + let task = Task { [weak self] in + await self?.storeContinuation(continuation) + } + + continuation.onTermination = { termination in + task.cancel() + } + } + } + + nonisolated + public func send(_ element: Element) { + Task { await _send(element) } + } + + func _send(_ element: Element) { + let tasks = tasks + for task in tasks { + task.yield(element) + } + } + + func storeContinuation(_ continuation: AsyncStream.Continuation) { + tasks.append(continuation) + } + + nonisolated + public func finish() { + Task { await _finish() } + } + + func _finish() { + let tasks = self.tasks + self.tasks = [] + for task in tasks { + task.finish() + } + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift new file mode 100644 index 0000000..9e30dd4 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -0,0 +1,21 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import ConversationServiceProvider + +public typealias CopilotForXcodeCapability = CopilotForXcodeExtensionCapability & CopilotForXcodeChatCapability + +public protocol CopilotForXcodeChatCapability { + /// Not implemented yet. + var conversationService: ConversationServiceType? { get } +} + +public protocol BuiltinExtension: CopilotForXcodeCapability { + /// An id that let the extension manager determine whether the extension is in use. + var suggestionServiceId: BuiltInSuggestionFeatureProvider { get } + + /// It's usually called when the app is about to quit, + /// you should clean up all the resources here. + func terminate() +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift new file mode 100644 index 0000000..b7ab3e3 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift @@ -0,0 +1,100 @@ +import ConversationServiceProvider +import CopilotForXcodeKit +import Foundation +import Logger +import XcodeInspector + +public final class BuiltinExtensionConversationServiceProvider< + T: BuiltinExtension +>: ConversationServiceProvider { + + private let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var conversationService: ConversationServiceType? { + extensionManager.extensions.first { $0 is T }?.conversationService + } + + 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 BuiltinExtensionChatServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin chat service not found." + } + } + + public func createConversation(_ request: ConversationRequest) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return + } + + try await conversationService.createConversation(request, workspace: workspaceInfo) + } + + public func createTurn(with conversationId: String, request: ConversationRequest) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return + } + + try await conversationService.createTurn(with: conversationId, request: request, workspace: workspaceInfo) + } + + public func stopReceivingMessage(_ workDoneToken: String) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return + } + + try await conversationService.cancelProgress(workDoneToken, workspace: workspaceInfo) + } + + public func rateConversation(turnId: String, rating: ConversationRating) async throws { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace() 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 { + guard let conversationService else { + Logger.service.error("Builtin chat service not found.") + return + } + guard let workspaceInfo = await activeWorkspace() else { + Logger.service.error("Could not get active workspace info") + return + } + try? await conversationService.copyCode(request: request, workspace: workspaceInfo) + } +} diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift new file mode 100644 index 0000000..0d65011 --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -0,0 +1,46 @@ +import AppKit +import Combine +import Foundation +import XcodeInspector + +public final class BuiltinExtensionManager { + public static let shared: BuiltinExtensionManager = .init() + private(set) var extensions: [any BuiltinExtension] = [] + + private var cancellable: Set = [] + + init() { + XcodeInspector.shared.$activeApplication.sink { [weak self] app in + if let app, app.isXcode || app.isExtensionService { + self?.checkAppConfiguration() + } + }.store(in: &cancellable) + } + + public func setupExtensions(_ extensions: [any BuiltinExtension]) { + self.extensions = extensions + checkAppConfiguration() + } + + public func terminate() { + for ext in extensions { + ext.terminate() + } + } +} + +extension BuiltinExtensionManager { + func checkAppConfiguration() { + let suggestionFeatureProvider = UserDefaults.shared.value(for: \.suggestionFeatureProvider) + for ext in extensions { + let isSuggestionFeatureInUse = suggestionFeatureProvider == + .builtIn(ext.suggestionServiceId) + let isChatFeatureInUse = true + ext.extensionUsageDidChange(.init( + isSuggestionServiceInUse: isSuggestionFeatureInUse, + isChatServiceInUse: isChatFeatureInUse + )) + } + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift new file mode 100644 index 0000000..f6234dd --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionSuggestionServiceProvider.swift @@ -0,0 +1,177 @@ +import CopilotForXcodeKit +import Foundation +import Logger +import Preferences +import SuggestionBasic +import SuggestionProvider + +public final class BuiltinExtensionSuggestionServiceProvider< + T: BuiltinExtension +>: SuggestionServiceProvider { + public var configuration: SuggestionServiceConfiguration { + guard let service else { + return .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ) + } + + return service.configuration + } + + let extensionManager: BuiltinExtensionManager + + public init( + extension: T.Type, + extensionManager: BuiltinExtensionManager = .shared + ) { + self.extensionManager = extensionManager + } + + var service: CopilotForXcodeKit.SuggestionServiceType? { + extensionManager.extensions.first { $0 is T }?.suggestionService + } + + struct BuiltinExtensionSuggestionServiceNotFoundError: Error, LocalizedError { + var errorDescription: String? { + "Builtin suggestion service not found." + } + } + + public func getSuggestions( + _ 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.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 + ).map { $0.converted } + } + + public func cancelRequest( + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.cancelRequest(workspace: workspaceInfo) + } + + public func notifyAccepted( + _ suggestion: SuggestionBasic.CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyAccepted(suggestion.converted, workspace: workspaceInfo) + } + + public func notifyRejected( + _ suggestions: [SuggestionBasic.CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async { + guard let service else { + Logger.service.error("Builtin suggestion service not found.") + return + } + await service.notifyRejected(suggestions.map(\.converted), workspace: workspaceInfo) + } +} + +extension SuggestionProvider.SuggestionRequest { + var converted: CopilotForXcodeKit.SuggestionRequest { + .init( + fileURL: fileURL, + relativePath: relativePath, + language: .init(rawValue: languageIdentifierFromFileURL(fileURL).rawValue) + ?? .plaintext, + content: content, + originalContent: originalContent, + cursorPosition: .init( + line: cursorPosition.line, + character: cursorPosition.character + ), + tabSize: tabSize, + indentSize: indentSize, + usesTabsForIndentation: usesTabsForIndentation, + relevantCodeSnippets: relevantCodeSnippets.map(\.converted) + ) + } +} + +extension SuggestionBasic.CodeSuggestion { + var converted: CopilotForXcodeKit.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension CopilotForXcodeKit.CodeSuggestion { + var converted: SuggestionBasic.CodeSuggestion { + .init( + id: id, + text: text, + position: .init( + line: position.line, + character: position.character + ), + range: .init( + start: .init( + line: range.start.line, + character: range.start.character + ), + end: .init( + line: range.end.line, + character: range.end.character + ) + ) + ) + } +} + +extension SuggestionProvider.RelevantCodeSnippet { + var converted: CopilotForXcodeKit.RelevantCodeSnippet { + .init(content: content, priority: priority, filePath: filePath) + } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift new file mode 100644 index 0000000..a03c34d --- /dev/null +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -0,0 +1,72 @@ +import Foundation +import Workspace + +public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { + let extensionManager: BuiltinExtensionManager + + public init(workspace: Workspace, extensionManager: BuiltinExtensionManager = .shared) { + self.extensionManager = extensionManager + super.init(workspace: workspace) + } + + override public func didOpenFilespace(_ filespace: Filespace) { + 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 didCloseFilespace(_ fileURL: URL) { + Task { + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didCloseDocumentAt: fileURL + ) + } + } + } + + 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 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 notifySaveFile(filespace: Filespace) { + Task { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didSaveDocumentAt: filespace.fileURL + ) + } + } + } +} + diff --git a/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift new file mode 100644 index 0000000..2b7dede --- /dev/null +++ b/Tool/Sources/ChatAPIService/APIs/ChatCompletionsAPIDefinition.swift @@ -0,0 +1,96 @@ +import CodableWrappers +import Foundation +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: + return .assistant + } + } + } + + /// The role of the message. + var role: Role + /// The content of the message. + + var content: String + } + + var messages: [Message] + var temperature: Double? + var stream: Bool? + var stop: [String]? + + init( + messages: [Message], + temperature: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil + ) { + self.messages = messages + self.temperature = temperature + self.stream = stream + self.stop = stop + } +} + +// MARK: - Stream API + +extension AsyncSequence { + func toStream() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + for try await element in self { + continuation.yield(element) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +struct ChatCompletionsStreamDataChunk { + struct Delta { + var role: ChatCompletionsRequestBody.Message.Role? + var content: String? + } + + var id: String? + var object: String? + var model: String? + var message: Delta? + var finishReason: String? +} + +// MARK: - Non Stream API + +struct ChatCompletionResponseBody: Codable, Equatable { + typealias Message = ChatCompletionsRequestBody.Message + + var id: String? + var object: String + var message: Message + var otherChoices: [Message] + var finishReason: String +} + diff --git a/Tool/Sources/ChatAPIService/APIs/ResponseStream.swift b/Tool/Sources/ChatAPIService/APIs/ResponseStream.swift new file mode 100644 index 0000000..ce28b7f --- /dev/null +++ b/Tool/Sources/ChatAPIService/APIs/ResponseStream.swift @@ -0,0 +1,45 @@ +import Foundation + +struct ResponseStream: AsyncSequence { + func makeAsyncIterator() -> Stream.AsyncIterator { + stream.makeAsyncIterator() + } + + typealias Stream = AsyncThrowingStream + typealias AsyncIterator = Stream.AsyncIterator + typealias Element = Chunk + + struct LineContent { + let chunk: Chunk? + let done: Bool + } + + let stream: Stream + + init(result: URLSession.AsyncBytes, lineExtractor: @escaping (String) throws -> LineContent) { + stream = AsyncThrowingStream { continuation in + let task = Task { + do { + for try await line in result.lines { + if Task.isCancelled { break } + let content = try lineExtractor(line) + if let chunk = content.chunk { + continuation.yield(chunk) + } + + if content.done { break } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + result.task.cancel() + } + } + continuation.onTermination = { _ in + task.cancel() + result.task.cancel() + } + } + } +} + diff --git a/Tool/Sources/ChatAPIService/Debug/Debug.swift b/Tool/Sources/ChatAPIService/Debug/Debug.swift new file mode 100644 index 0000000..3186496 --- /dev/null +++ b/Tool/Sources/ChatAPIService/Debug/Debug.swift @@ -0,0 +1,75 @@ +import AppKit +import Foundation + +enum Debugger { + @TaskLocal + static var id: UUID? + + #if DEBUG + static func didSendRequestBody(body: ChatCompletionsRequestBody) { + do { + let json = try JSONEncoder().encode(body) + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.requestSent"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "data": json, + ] + ) + } catch { + print("Failed to encode request body: \(error)") + } + } + + static func didReceiveFunction(name: String, arguments: String) { + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.receivedFunctionCall"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "name": name, + "arguments": arguments, + ] + ) + } + + static func didReceiveFunctionResult(result: String) { + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.receivedFunctionResult"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "result": result, + ] + ) + } + + static func didReceiveResponse(content: String) { + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.responseReceived"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + "response": content, + ] + ) + } + + static func didFinish() { + let center = NotificationCenter.default + center.post( + name: .init("ServiceDebugger.ChatRequestDebug.finished"), + object: nil, + userInfo: [ + "id": id ?? UUID(), + ] + ) + } + #endif +} + diff --git a/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift new file mode 100644 index 0000000..ca9f06c --- /dev/null +++ b/Tool/Sources/ChatAPIService/Memory/AutoManagedChatMemory.swift @@ -0,0 +1,89 @@ +import Foundation +import Logger +import Preferences + +@globalActor +public enum AutoManagedChatMemoryActor: GlobalActor { + public actor Actor {} + public static let shared = Actor() +} + +protocol AutoManagedChatMemoryStrategy { + func countToken(_ message: ChatMessage) async -> Int +} + +/// A memory that automatically manages the history according to max tokens and max message count. +public actor AutoManagedChatMemory: ChatMemory { + public struct ComposableMessages { + public var systemPromptMessage: ChatMessage + public var historyMessage: [ChatMessage] + public var retrievedContentMessage: ChatMessage + public var contextSystemPromptMessage: ChatMessage + public var newMessage: ChatMessage + } + + public typealias HistoryComposer = (ComposableMessages) -> [ChatMessage] + + public private(set) var history: [ChatMessage] = [] { + didSet { onHistoryChange() } + } + + public private(set) var remainingTokens: Int? + + public var systemPrompt: String + public var contextSystemPrompt: String + public var retrievedContent: [ChatMessage.Reference] = [] + + var onHistoryChange: () -> Void = {} + + let composeHistory: HistoryComposer + + public init( + systemPrompt: String, + composeHistory: @escaping HistoryComposer = { + /// Default Format: + /// ``` + /// [System Prompt] priority: high + /// [Functions] priority: high + /// [Retrieved Content] priority: low + /// [Retrieved Content A] + /// + /// [Retrieved Content B] + /// [Message History] priority: medium + /// [Context System Prompt] priority: high + /// [Latest Message] priority: high + /// ``` + [$0.systemPromptMessage] + + $0.historyMessage + + [$0.retrievedContentMessage, $0.contextSystemPromptMessage, $0.newMessage] + } + ) { + self.systemPrompt = systemPrompt + contextSystemPrompt = "" + self.composeHistory = composeHistory + } + + public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) { + update(&history) + } + + public func mutateContextSystemPrompt(_ newPrompt: String) { + contextSystemPrompt = newPrompt + } + + public func mutateRetrievedContent(_ newContent: [ChatMessage.Reference]) { + retrievedContent = newContent + } + + public nonisolated + func observeHistoryChange(_ onChange: @escaping () -> Void) { + Task { + await setOnHistoryChangeBlock(onChange) + } + } + + func setOnHistoryChangeBlock(_ onChange: @escaping () -> Void) { + onHistoryChange = onChange + } +} + diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift new file mode 100644 index 0000000..19c6d3b --- /dev/null +++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift @@ -0,0 +1,33 @@ +import Foundation + +public protocol ChatMemory { + /// The message history. + var history: [ChatMessage] { get async } + /// Update the message history. + func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async +} + +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 + } else { + history.append(message) + } + } + } + + /// Remove a message from the history. + func removeMessage(_ id: String) async { + await mutateHistory { + $0.removeAll { $0.id == id } + } + } + + /// Clear the history. + func clearHistory() async { + await mutateHistory { $0.removeAll() } + } +} diff --git a/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift new file mode 100644 index 0000000..0b6fc8e --- /dev/null +++ b/Tool/Sources/ChatAPIService/Memory/ConversationChatMemory.swift @@ -0,0 +1,14 @@ +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) + } +} + diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift new file mode 100644 index 0000000..80fbc39 --- /dev/null +++ b/Tool/Sources/ChatAPIService/Models.swift @@ -0,0 +1,107 @@ +import CodableWrappers +import Foundation +import ConversationServiceProvider + +public struct ChatMessage: Equatable, Codable { + public typealias ID = String + + public enum Role: String, Codable, Equatable { + case system + case user + case 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 + } + } + + /// 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 id of the message. + public var id: ID + + /// The turn id of the message. + public var turnId: ID? + + /// Rate assistant message + public var rating: ConversationRating = .unrated + + /// The references of this message. + @FallbackDecoding> + public var references: [Reference] + + public init( + id: String = UUID().uuidString, + role: Role, + turnId: String? = nil, + content: String, + summary: String? = nil, + references: [Reference] = [] + ) { + self.role = role + self.content = content + self.summary = summary + self.id = id + self.turnId = turnId + self.references = references + } +} + +public struct ReferenceKindFallback: FallbackValueProvider { + public static var defaultValue: ChatMessage.Reference.Kind { .other } +} + +public struct ChatMessageRoleFallback: FallbackValueProvider { + public static var defaultValue: ChatMessage.Role { .user } +} + diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift new file mode 100644 index 0000000..9396373 --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -0,0 +1,236 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +/// The information of a tab. +@ObservableState +public struct ChatTabInfo: Identifiable, Equatable { + public var id: String + public var title: String + public var focusTrigger: Int = 0 + + public init(id: String, title: String) { + self.id = id + self.title = title + } +} + +/// Every chat tab should conform to this type. +public typealias ChatTab = BaseChatTab & ChatTabType + +/// Defines a bunch of things a chat tab should implement. +public protocol ChatTabType { + /// The type of the external dependency required by this chat tab. + associatedtype ExternalDependency + /// Build the view for this chat tab. + @ViewBuilder + func buildView() -> any View + /// Build the tabItem for this chat tab. + @ViewBuilder + func buildTabItem() -> any View + /// Build the icon for this chat tab. + @ViewBuilder + func buildIcon() -> any View + /// Build the menu for this chat tab. + @ViewBuilder + func buildMenu() -> any View + /// The name of this chat tab type. + static var name: String { get } + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders(externalDependency: ExternalDependency) -> [ChatTabBuilder] + /// Restorable state + func restorableState() async -> Data + /// Restore state + static func restore( + from data: Data, + externalDependency: ExternalDependency + ) async throws -> any ChatTabBuilder + /// Whenever the body or menu is accessed, this method will be called. + /// It will be called only once so long as you don't call it yourself. + /// It will be called from MainActor. + func start() +} + +/// The base class for all chat tabs. +open class BaseChatTab { + /// A wrapper to support dynamic update of title in view. + struct ContentView: View { + var buildView: () -> any View + var body: some View { + AnyView(buildView()) + } + } + + public var id: String = "" + public var title: String = "" + /// The store for chat tab info. You should only access it after `start` is called. + public let chatTabStore: StoreOf + + private var didStart = false + private let storeObserver = NSObject() + + public init(store: StoreOf) { + chatTabStore = store + + storeObserver.observe { [weak self] in + guard let self else { return } + self.title = store.title + self.id = store.id + } + } + + /// The view for this chat tab. + @ViewBuilder + public var body: some View { + let id = "ChatTabBody\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildView).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + /// The tab item for this chat tab. + @ViewBuilder + public var tabItem: some View { + let id = "ChatTabTab\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildTabItem).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + /// The icon for this chat tab. + @ViewBuilder + public var icon: some View { + let id = "ChatTabIcon\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildIcon).id(id) + } else { + EmptyView().id(id) + } + } + + /// The tab item for this chat tab. + @ViewBuilder + public var menu: some View { + let id = "ChatTabMenu\(id)" + if let tab = self as? (any ChatTabType) { + ContentView(buildView: tab.buildMenu).id(id) + .onAppear { + Task { @MainActor in self.startIfNotStarted() } + } + } else { + EmptyView().id(id) + } + } + + @MainActor + func startIfNotStarted() { + guard !didStart else { return } + didStart = true + + if let tab = self as? (any ChatTabType) { + tab.start() + } + } +} + +/// A factory of a chat tab. +public protocol ChatTabBuilder { + /// A visible title for user. + var title: String { get } + /// Build the chat tab. + func build(store: StoreOf) async -> (any ChatTab)? +} + +/// A chat tab builder that doesn't build. +public struct DisabledChatTabBuilder: ChatTabBuilder { + public var title: String + public func build(store: StoreOf) async -> (any ChatTab)? { + return nil + } + + public init(title: String) { + self.title = title + } +} + +public extension ChatTabType { + /// The name of this chat tab type. + var name: String { Self.name } +} + +public extension ChatTabType where ExternalDependency == Void { + /// Available builders for this chat tab. + /// It's used to generate a list of tab types for user to create. + static func chatBuilders() -> [ChatTabBuilder] { + chatBuilders(externalDependency: ()) + } +} + +/// A chat tab that does nothing. +public class EmptyChatTab: ChatTab { + public static var name: String { "Empty" } + + struct Builder: ChatTabBuilder { + let title: String + func build(store: StoreOf) async -> (any ChatTab)? { + EmptyChatTab(store: store) + } + } + + public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] { + [Builder(title: "Empty")] + } + + public func buildView() -> any View { + VStack { + Text("Empty-\(id)") + } + .background(Color.blue) + } + + public func buildTabItem() -> any View { + Text("Empty-\(id)") + } + + public func buildIcon() -> any View { + Image(systemName: "square") + } + + public func buildMenu() -> any View { + Text("Empty-\(id)") + } + + public func restorableState() async -> Data { + return Data() + } + + public static func restore( + from data: Data, + externalDependency: Void + ) async throws -> any ChatTabBuilder { + return Builder(title: "Empty") + } + + public convenience init(id: String) { + self.init(store: .init( + initialState: .init(id: id, title: "Empty-\(id)"), + reducer: { ChatTabItem() } + )) + } + + public func start() { + chatTabStore.send(.updateTitle("Empty-\(id)")) + } +} + diff --git a/Tool/Sources/ChatTab/ChatTabItem.swift b/Tool/Sources/ChatTab/ChatTabItem.swift new file mode 100644 index 0000000..abf7aaa --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabItem.swift @@ -0,0 +1,49 @@ +import ComposableArchitecture +import Foundation + +public struct AnyChatTabBuilder: Equatable { + public static func == (lhs: AnyChatTabBuilder, rhs: AnyChatTabBuilder) -> Bool { + true + } + + public let chatTabBuilder: any ChatTabBuilder + + public init(_ chatTabBuilder: any ChatTabBuilder) { + self.chatTabBuilder = chatTabBuilder + } +} + +@Reducer +public struct ChatTabItem { + public typealias State = ChatTabInfo + + public enum Action: Equatable { + case updateTitle(String) + case openNewTab(AnyChatTabBuilder) + case tabContentUpdated + case close + case focus + } + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .updateTitle(title): + state.title = title + return .none + case .openNewTab: + return .none + case .tabContentUpdated: + return .none + case .close: + return .none + case .focus: + state.focusTrigger += 1 + return .none + } + } + } +} + diff --git a/Tool/Sources/ChatTab/ChatTabPool.swift b/Tool/Sources/ChatTab/ChatTabPool.swift new file mode 100644 index 0000000..fafa22c --- /dev/null +++ b/Tool/Sources/ChatTab/ChatTabPool.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI + +/// A pool that stores all the available tabs. +public final class ChatTabPool { + public var createStore: (String) -> StoreOf = { id in + .init( + initialState: .init(id: id, title: ""), + reducer: { ChatTabItem() } + ) + } + + private var pool: [String: any ChatTab] + + public init(_ pool: [String: any ChatTab] = [:]) { + self.pool = pool + } + + public func getTab(of id: String) -> (any ChatTab)? { + pool[id] + } + + public func setTab(_ tab: any ChatTab) { + pool[tab.id] = tab + } + + public func removeTab(of id: String) { + pool.removeValue(forKey: id) + } +} + +public struct ChatTabPoolDependencyKey: DependencyKey { + public static let liveValue = ChatTabPool() +} + +public extension DependencyValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolDependencyKey.self] } + set { self[ChatTabPoolDependencyKey.self] = newValue } + } +} + +public struct ChatTabPoolEnvironmentKey: EnvironmentKey { + public static let defaultValue = ChatTabPool() +} + +public extension EnvironmentValues { + var chatTabPool: ChatTabPool { + get { self[ChatTabPoolEnvironmentKey.self] } + set { self[ChatTabPoolEnvironmentKey.self] = newValue } + } +} + diff --git a/Tool/Sources/Configs/Configurations.swift b/Tool/Sources/Configs/Configurations.swift new file mode 100644 index 0000000..5c6acec --- /dev/null +++ b/Tool/Sources/Configs/Configurations.swift @@ -0,0 +1,13 @@ +import Foundation + +private var teamIDPrefix: String { + Bundle.main.infoDictionary?["TEAM_ID_PREFIX"] as? String ?? "" +} + +private var bundleIdentifierBase: String { + Bundle.main.infoDictionary?["BUNDLE_IDENTIFIER_BASE"] as? String ?? "" +} + +public var userDefaultSuiteName: String { + "\(teamIDPrefix)group.\(bundleIdentifierBase).prefs" +} diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift new file mode 100644 index 0000000..22e987f --- /dev/null +++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift @@ -0,0 +1,65 @@ +import CopilotForXcodeKit + +public protocol ConversationServiceType { + func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws + func createTurn(with conversationId: String, request: ConversationRequest, 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 +} + +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 +} + +public struct ConversationRequest { + public var workDoneToken: String + public var content: String + public var workspaceFolder: String + public var skills: [String] + + public init( + workDoneToken: String, + content: String, + workspaceFolder: String, + skills: [String] + ) { + self.workDoneToken = workDoneToken + self.content = content + self.workspaceFolder = workspaceFolder + self.skills = skills + } +} + +public struct CopyCodeRequest { + public var turnId: String + public var codeBlockIndex: Int + public var copyType: CopyKind + public var copiedCharacters: Int + public var totalCharacters: Int + public var copiedText: String + + init(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) { + self.turnId = turnId + self.codeBlockIndex = codeBlockIndex + self.copyType = copyType + self.copiedCharacters = copiedCharacters + self.totalCharacters = totalCharacters + self.copiedText = copiedText + } +} + +public enum ConversationRating: Int, Codable { + case unrated = 0 + case helpful = 1 + case unhelpful = -1 +} + +public enum CopyKind: Int, Codable { + case keyboard = 1 + case toolbar = 2 +} diff --git a/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift new file mode 100644 index 0000000..df296cc --- /dev/null +++ b/Tool/Sources/CustomAsyncAlgorithms/TimedDebounce.swift @@ -0,0 +1,69 @@ +import Foundation + +private actor TimedDebounceFunction { + let duration: TimeInterval + let block: (Element) async -> Void + + var task: Task? + var lastValue: Element? + var lastFireTime: Date = .init(timeIntervalSince1970: 0) + + init(duration: TimeInterval, block: @escaping (Element) async -> Void) { + self.duration = duration + self.block = block + } + + func callAsFunction(_ value: Element) async { + task?.cancel() + if lastFireTime.timeIntervalSinceNow < -duration { + await fire(value) + task = nil + } else { + lastValue = value + task = Task.detached { [weak self, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await self?.fire(value) + } + } + } + + func finish() async { + task?.cancel() + if let lastValue { + await fire(lastValue) + } + } + + private func fire(_ value: Element) async { + lastFireTime = Date() + lastValue = nil + await block(value) + } +} + +public extension AsyncSequence { + /// Debounce, but only if the value is received within a certain time frame. + /// + /// In the future when we drop macOS 12 support we should just use chunked from AsyncAlgorithms. + func timedDebounce( + for duration: TimeInterval + ) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + let function = TimedDebounceFunction(duration: duration) { value in + continuation.yield(value) + } + do { + for try await value in self { + await function(value) + } + await function.finish() + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} + diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift new file mode 100644 index 0000000..66a5fdd --- /dev/null +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -0,0 +1,48 @@ +import Foundation + +public actor DebounceFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + + public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func cancel() { + task?.cancel() + } + + public func callAsFunction(_ t: T) async { + task?.cancel() + task = Task { [block, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block(t) + } + } +} + +public actor DebounceRunner { + let duration: TimeInterval + + var task: Task? + + public init(duration: TimeInterval) { + self.duration = duration + } + + public func cancel() { + task?.cancel() + } + + public func debounce(_ block: @escaping () async -> Void) { + task?.cancel() + task = Task { [duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block() + } + } +} + diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift new file mode 100644 index 0000000..3a0771c --- /dev/null +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -0,0 +1,42 @@ +import Foundation + +public actor ThrottleFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + var lastFinishTime: Date = .init(timeIntervalSince1970: 0) + var now: () -> Date = { Date() } + + public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func callAsFunction(_ t: T) async { + if task == nil { + scheduleTask(t, wait: now().timeIntervalSince(lastFinishTime) < duration) + } + } + + func scheduleTask(_ t: T, wait: Bool) { + task = Task.detached { [weak self] in + guard let self else { return } + do { + if wait { + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + } + await block(t) + await finishTask() + } catch { + await finishTask() + } + } + } + + func finishTask() { + task = nil + lastFinishTime = now() + } +} + diff --git a/Tool/Sources/FileSystem/ByteString.swift b/Tool/Sources/FileSystem/ByteString.swift new file mode 100644 index 0000000..af4a3b4 --- /dev/null +++ b/Tool/Sources/FileSystem/ByteString.swift @@ -0,0 +1,160 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +/// A `ByteString` represents a sequence of bytes. +/// +/// This struct provides useful operations for working with buffers of +/// bytes. Conceptually it is just a contiguous array of bytes (UInt8), but it +/// contains methods and default behavior suitable for common operations done +/// using bytes strings. +/// +/// This struct *is not* intended to be used for significant mutation of byte +/// strings, we wish to retain the flexibility to micro-optimize the memory +/// allocation of the storage (for example, by inlining the storage for small +/// strings or and by eliminating wasted space in growable arrays). For +/// construction of byte arrays, clients should use the `WritableByteStream` class +/// and then convert to a `ByteString` when complete. +public struct ByteString: ExpressibleByArrayLiteral, Hashable, Sendable { + /// The buffer contents. + @usableFromInline + internal var _bytes: [UInt8] + + /// Create an empty byte string. + @inlinable + public init() { + _bytes = [] + } + + /// Create a byte string from a byte array literal. + @inlinable + public init(arrayLiteral contents: UInt8...) { + _bytes = contents + } + + /// Create a byte string from an array of bytes. + @inlinable + public init(_ contents: [UInt8]) { + _bytes = contents + } + + /// Create a byte string from an array slice. + @inlinable + public init(_ contents: ArraySlice) { + _bytes = Array(contents) + } + + /// Create a byte string from an byte buffer. + @inlinable + public init (_ contents: S) where S.Iterator.Element == UInt8 { + _bytes = [UInt8](contents) + } + + /// Create a byte string from the UTF8 encoding of a string. + @inlinable + public init(encodingAsUTF8 string: String) { + _bytes = [UInt8](string.utf8) + } + + /// Access the byte string contents as an array. + @inlinable + public var contents: [UInt8] { + return _bytes + } + + /// Return the byte string size. + @inlinable + public var count: Int { + return _bytes.count + } + + /// Gives a non-escaping closure temporary access to an immutable `Data` instance wrapping the `ByteString` without + /// copying any memory around. + /// + /// - Parameters: + /// - closure: The closure that will have access to a `Data` instance for the duration of its lifetime. + @inlinable + public func withData(_ closure: (Data) throws -> T) rethrows -> T { + return try _bytes.withUnsafeBytes { pointer -> T in + let mutatingPointer = UnsafeMutableRawPointer(mutating: pointer.baseAddress!) + let data = Data(bytesNoCopy: mutatingPointer, count: pointer.count, deallocator: .none) + return try closure(data) + } + } + + /// Returns a `String` lowercase hexadecimal representation of the contents of the `ByteString`. + @inlinable + public var hexadecimalRepresentation: String { + _bytes.reduce("") { + var str = String($1, radix: 16) + // The above method does not do zero padding. + if str.count == 1 { + str = "0" + str + } + return $0 + str + } + } +} + +/// Conform to CustomDebugStringConvertible. +extension ByteString: CustomStringConvertible { + /// Return the string decoded as a UTF8 sequence, or traps if not possible. + public var description: String { + return cString + } + + /// Return the string decoded as a UTF8 sequence, if possible. + @inlinable + public var validDescription: String? { + // FIXME: This is very inefficient, we need a way to pass a buffer. It + // is also wrong if the string contains embedded '\0' characters. + let tmp = _bytes + [UInt8(0)] + return tmp.withUnsafeBufferPointer { ptr in + return String(validatingUTF8: unsafeBitCast(ptr.baseAddress, to: UnsafePointer.self)) + } + } + + /// Return the string decoded as a UTF8 sequence, substituting replacement + /// characters for ill-formed UTF8 sequences. + @inlinable + public var cString: String { + return String(decoding: _bytes, as: Unicode.UTF8.self) + } + + @available(*, deprecated, message: "use description or validDescription instead") + public var asString: String? { + return validDescription + } +} + +/// ByteStreamable conformance for a ByteString. +extension ByteString: ByteStreamable { + @inlinable + public func write(to stream: WritableByteStream) { + stream.write(_bytes) + } +} + +/// StringLiteralConvertable conformance for a ByteString. +extension ByteString: ExpressibleByStringLiteral { + public typealias UnicodeScalarLiteralType = StringLiteralType + public typealias ExtendedGraphemeClusterLiteralType = StringLiteralType + + public init(unicodeScalarLiteral value: UnicodeScalarLiteralType) { + _bytes = [UInt8](value.utf8) + } + public init(extendedGraphemeClusterLiteral value: ExtendedGraphemeClusterLiteralType) { + _bytes = [UInt8](value.utf8) + } + public init(stringLiteral value: StringLiteralType) { + _bytes = [UInt8](value.utf8) + } +} diff --git a/Tool/Sources/FileSystem/FileInfo.swift b/Tool/Sources/FileSystem/FileInfo.swift new file mode 100644 index 0000000..54a2e54 --- /dev/null +++ b/Tool/Sources/FileSystem/FileInfo.swift @@ -0,0 +1,66 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Foundation + +#if swift(<5.6) +extension FileAttributeType: UnsafeSendable {} +extension Date: UnsafeSendable {} +#endif + +/// File system information for a particular file. +public struct FileInfo: Equatable, Codable, Sendable { + + /// The device number. + public let device: UInt64 + + /// The inode number. + public let inode: UInt64 + + /// The size of the file. + public let size: UInt64 + + /// The modification time of the file. + public let modTime: Date + + /// Kind of file system entity. + public let posixPermissions: Int16 + + /// Kind of file system entity. + public let fileType: FileAttributeType + + public init(_ attrs: [FileAttributeKey : Any]) { + let device = (attrs[.systemNumber] as? NSNumber)?.uint64Value + assert(device != nil) + self.device = device! + + let inode = attrs[.systemFileNumber] as? UInt64 + assert(inode != nil) + self.inode = inode! + + let posixPermissions = (attrs[.posixPermissions] as? NSNumber)?.int16Value + assert(posixPermissions != nil) + self.posixPermissions = posixPermissions! + + let fileType = attrs[.type] as? FileAttributeType + assert(fileType != nil) + self.fileType = fileType! + + let size = attrs[.size] as? UInt64 + assert(size != nil) + self.size = size! + + let modTime = attrs[.modificationDate] as? Date + assert(modTime != nil) + self.modTime = modTime! + } +} + +extension FileAttributeType: Codable {} diff --git a/Tool/Sources/FileSystem/FileSystem.swift b/Tool/Sources/FileSystem/FileSystem.swift new file mode 100644 index 0000000..39f0bed --- /dev/null +++ b/Tool/Sources/FileSystem/FileSystem.swift @@ -0,0 +1,1303 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Dispatch +import Foundation + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +public struct FileSystemError: Error, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + /// Access to the path is denied. + /// + /// This is used when an operation cannot be completed because a component of + /// the path cannot be accessed. + /// + /// Used in situations that correspond to the POSIX EACCES error code. + case invalidAccess + + /// IO Error encoding + /// + /// This is used when an operation cannot be completed due to an otherwise + /// unspecified IO error. + case ioError(code: Int32) + + /// Is a directory + /// + /// This is used when an operation cannot be completed because a component + /// of the path which was expected to be a file was not. + /// + /// Used in situations that correspond to the POSIX EISDIR error code. + case isDirectory + + /// No such path exists. + /// + /// This is used when a path specified does not exist, but it was expected + /// to. + /// + /// Used in situations that correspond to the POSIX ENOENT error code. + case noEntry + + /// Not a directory + /// + /// This is used when an operation cannot be completed because a component + /// of the path which was expected to be a directory was not. + /// + /// Used in situations that correspond to the POSIX ENOTDIR error code. + case notDirectory + + /// Unsupported operation + /// + /// This is used when an operation is not supported by the concrete file + /// system implementation. + case unsupported + + /// An unspecific operating system error at a given path. + case unknownOSError + + /// File or folder already exists at destination. + /// + /// This is thrown when copying or moving a file or directory but the destination + /// path already contains a file or folder. + case alreadyExistsAtDestination + + /// If an unspecified error occurs when trying to change directories. + case couldNotChangeDirectory + + /// If a mismatch is detected in byte count when writing to a file. + case mismatchedByteCount(expected: Int, actual: Int) + } + + /// The kind of the error being raised. + public let kind: Kind + + /// The absolute path to the file associated with the error, if available. + public let path: AbsolutePath? + + public init(_ kind: Kind, _ path: AbsolutePath? = nil) { + self.kind = kind + self.path = path + } +} + +extension FileSystemError: CustomNSError { + public var errorUserInfo: [String: Any] { + return [NSLocalizedDescriptionKey: "\(self)"] + } +} + +public extension FileSystemError { + init(errno: Int32, _ path: AbsolutePath) { + switch errno { + case EACCES: + self.init(.invalidAccess, path) + case EISDIR: + self.init(.isDirectory, path) + case ENOENT: + self.init(.noEntry, path) + case ENOTDIR: + self.init(.notDirectory, path) + case EEXIST: + self.init(.alreadyExistsAtDestination, path) + default: + self.init(.ioError(code: errno), path) + } + } +} + +/// Defines the file modes. +public enum FileMode: Sendable { + public enum Option: Int, Sendable { + case recursive + case onlyFiles + } + + case userUnWritable + case userWritable + case executable + + public func setMode(_ originalMode: Int16) -> Int16 { + switch self { + case .userUnWritable: + // r-x rwx rwx + return originalMode & 0o577 + case .userWritable: + // -w- --- --- + return originalMode | 0o200 + case .executable: + // --x --x --x + return originalMode | 0o111 + } + } +} + +/// Extended file system attributes that can applied to a given file path. See also +/// ``FileSystem/hasAttribute(_:_:)``. +public enum FileSystemAttribute: RawRepresentable { + #if canImport(Darwin) + case quarantine + #endif + + public init?(rawValue: String) { + switch rawValue { + #if canImport(Darwin) + case "com.apple.quarantine": + self = .quarantine + #endif + default: + return nil + } + } + + public var rawValue: String { + switch self { + #if canImport(Darwin) + case .quarantine: + return "com.apple.quarantine" + #endif + } + } +} + +// FIXME: Design an asynchronous story? +// +/// Abstracted access to file system operations. +/// +/// This protocol is used to allow most of the codebase to interact with a +/// natural filesystem interface, while still allowing clients to transparently +/// substitute a virtual file system or redirect file system operations. +/// +/// - Note: All of these APIs are synchronous and can block. +public protocol FileSystem: Sendable { + /// Check whether the given path exists and is accessible. + @_disfavoredOverload + func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool + + /// Check whether the given path is accessible and a directory. + func isDirectory(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and a file. + func isFile(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is an accessible and executable file. + func isExecutableFile(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and is a symbolic link. + func isSymlink(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and readable. + func isReadable(_ path: AbsolutePath) -> Bool + + /// Check whether the given path is accessible and writable. + func isWritable(_ path: AbsolutePath) -> Bool + + /// Returns any known item replacement directories for a given path. These may be used by + /// platform-specific + /// libraries to handle atomic file system operations, such as deletion. + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] + + @available(*, deprecated, message: "use `hasAttribute(_:_:)` instead") + func hasQuarantineAttribute(_ path: AbsolutePath) -> Bool + + /// Returns `true` if a given path has an attribute with a given name applied when file system + /// supports this + /// attribute. Returns `false` if such attribute is not applied or it isn't supported. + func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool + + // FIXME: Actual file system interfaces will allow more efficient access to + // more data than just the name here. + // + /// Get the contents of the given directory, in an undefined order. + func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] + + /// Get the current working directory (similar to `getcwd(3)`), which can be + /// different for different (virtualized) implementations of a FileSystem. + /// The current working directory can be empty if e.g. the directory became + /// unavailable while the current process was still working in it. + /// This follows the POSIX `getcwd(3)` semantics. + @_disfavoredOverload + var currentWorkingDirectory: AbsolutePath? { get } + + /// Change the current working directory. + /// - Parameters: + /// - path: The path to the directory to change the current working directory to. + func changeCurrentWorkingDirectory(to path: AbsolutePath) throws + + /// Get the home directory of current user + @_disfavoredOverload + var homeDirectory: AbsolutePath { get throws } + + /// Get the caches directory of current user + @_disfavoredOverload + var cachesDirectory: AbsolutePath? { get } + + /// Get the temp directory + @_disfavoredOverload + var tempDirectory: AbsolutePath { get throws } + + /// Create the given directory. + func createDirectory(_ path: AbsolutePath) throws + + /// Create the given directory. + /// + /// - recursive: If true, create missing parent directories if possible. + func createDirectory(_ path: AbsolutePath, recursive: Bool) throws + + /// Creates a symbolic link of the source path at the target path + /// - Parameters: + /// - path: The path at which to create the link. + /// - destination: The path to which the link points to. + /// - relative: If `relative` is true, the symlink contents will be a relative path, otherwise + /// it will be absolute. + func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws + + func data(_ path: AbsolutePath) throws -> Data + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Get the contents of a file. + /// + /// - Returns: The file contents as bytes, or nil if missing. + func readFileContents(_ path: AbsolutePath) throws -> ByteString + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Write the contents of a file. + func writeFileContents(_ path: AbsolutePath, bytes: ByteString) throws + + // FIXME: This is obviously not a very efficient or flexible API. + // + /// Write the contents of a file. + func writeFileContents(_ path: AbsolutePath, bytes: ByteString, atomically: Bool) throws + + /// Recursively deletes the file system entity at `path`. + /// + /// If there is no file system entity at `path`, this function does nothing (in particular, this + /// is not considered + /// to be an error). + func removeFileTree(_ path: AbsolutePath) throws + + /// Change file mode. + func chmod(_ mode: FileMode, path: AbsolutePath, options: Set) throws + + /// Returns the file info of the given path. + /// + /// The method throws if the underlying stat call fails. + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo + + /// Copy a file or directory. + func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws + + /// Move a file or directory. + func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws + + /// Execute the given block while holding the lock. + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T + + /// Execute the given block while holding the lock. + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T +} + +/// Convenience implementations (default arguments aren't permitted in protocol +/// methods). +public extension FileSystem { + /// exists override with default value. + @_disfavoredOverload + func exists(_ path: AbsolutePath) -> Bool { + return exists(path, followSymlink: true) + } + + /// Default implementation of createDirectory(_:) + func createDirectory(_ path: AbsolutePath) throws { + try createDirectory(path, recursive: false) + } + + // Change file mode. + func chmod(_ mode: FileMode, path: AbsolutePath) throws { + try chmod(mode, path: path, options: []) + } + + // Unless the file system type provides an override for this method, throw + // if `atomically` is `true`, otherwise fall back to whatever implementation already exists. + @_disfavoredOverload + func writeFileContents(_ path: AbsolutePath, bytes: ByteString, atomically: Bool) throws { + guard !atomically else { + throw FileSystemError(.unsupported, path) + } + try writeFileContents(path, bytes: bytes) + } + + /// Write to a file from a stream producer. + @_disfavoredOverload + func writeFileContents(_ path: AbsolutePath, body: (WritableByteStream) -> Void) throws { + let contents = BufferedOutputByteStream() + body(contents) + try createDirectory(path.parentDirectory, recursive: true) + try writeFileContents(path, bytes: contents.bytes) + } + + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo { + throw FileSystemError(.unsupported, path) + } + + func withLock(on path: AbsolutePath, _ body: () throws -> T) throws -> T { + return try withLock(on: path, type: .exclusive, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + _ body: () throws -> T + ) throws -> T { + return try withLock(on: path, type: type, blocking: true, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + throw FileSystemError(.unsupported, path) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + _ body: () async throws -> T + ) async throws -> T { + return try await withLock(on: path, type: type, blocking: true, body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T { + throw FileSystemError(.unsupported, path) + } + + func hasQuarantineAttribute(_: AbsolutePath) -> Bool { false } + + func hasAttribute(_: FileSystemAttribute, _: AbsolutePath) -> Bool { false } + + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] { [] } +} + +/// Concrete FileSystem implementation which communicates with the local file system. +private struct LocalFileSystem: FileSystem { + func isExecutableFile(_ path: AbsolutePath) -> Bool { + // Our semantics doesn't consider directories. + return (isFile(path) || isSymlink(path)) && FileManager.default + .isExecutableFile(atPath: path.pathString) + } + + func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool { + if followSymlink { + return FileManager.default.fileExists(atPath: path.pathString) + } + return (try? FileManager.default.attributesOfItem(atPath: path.pathString)) != nil + } + + func isDirectory(_ path: AbsolutePath) -> Bool { + var isDirectory: ObjCBool = false + let exists: Bool = FileManager.default.fileExists( + atPath: path.pathString, + isDirectory: &isDirectory + ) + return exists && isDirectory.boolValue + } + + func isFile(_ path: AbsolutePath) -> Bool { + guard let path = try? resolveSymlinks(path) else { + return false + } + let attrs = try? FileManager.default.attributesOfItem(atPath: path.pathString) + return attrs?[.type] as? FileAttributeType == .typeRegular + } + + func isSymlink(_ path: AbsolutePath) -> Bool { + let url = NSURL(fileURLWithPath: path.pathString) + // We are intentionally using `NSURL.resourceValues(forKeys:)` here since it improves + // performance on Darwin platforms. + let result = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) + return (result?[.isSymbolicLinkKey] as? Bool) == true + } + + func isReadable(_ path: AbsolutePath) -> Bool { + FileManager.default.isReadableFile(atPath: path.pathString) + } + + func isWritable(_ path: AbsolutePath) -> Bool { + FileManager.default.isWritableFile(atPath: path.pathString) + } + + func getFileInfo(_ path: AbsolutePath) throws -> FileInfo { + let attrs = try FileManager.default.attributesOfItem(atPath: path.pathString) + return FileInfo(attrs) + } + + func hasAttribute(_ name: FileSystemAttribute, _ path: AbsolutePath) -> Bool { + #if canImport(Darwin) + let bufLength = getxattr(path.pathString, name.rawValue, nil, 0, 0, 0) + + return bufLength > 0 + #else + return false + #endif + } + + var currentWorkingDirectory: AbsolutePath? { + let cwdStr = FileManager.default.currentDirectoryPath + + #if _runtime(_ObjC) + // The ObjC runtime indicates that the underlying Foundation has ObjC + // interoperability in which case the return type of + // `fileSystemRepresentation` is different from the Swift implementation + // of Foundation. + return try? AbsolutePath(validating: cwdStr) + #else + let fsr: UnsafePointer = cwdStr.fileSystemRepresentation + defer { fsr.deallocate() } + + return try? AbsolutePath(String(cString: fsr)) + #endif + } + + func changeCurrentWorkingDirectory(to path: AbsolutePath) throws { + guard isDirectory(path) else { + throw FileSystemError(.notDirectory, path) + } + + guard FileManager.default.changeCurrentDirectoryPath(path.pathString) else { + throw FileSystemError(.couldNotChangeDirectory, path) + } + } + + var homeDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: NSHomeDirectory()) + } + } + + var cachesDirectory: AbsolutePath? { + return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + .flatMap { try? AbsolutePath(validating: $0.path) } + } + + var tempDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: NSTemporaryDirectory()) + } + } + + func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] { + return try FileManager.default.contentsOfDirectory( + at: URL(fileURLWithPath: path.pathString), + includingPropertiesForKeys: includingPropertiesForKeys, + options: options + ).compactMap { try? AbsolutePath(validating: $0.path) } + } + + func createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + // Don't fail if path is already a directory. + if isDirectory(path) { return } + + try FileManager.default.createDirectory( + atPath: path.pathString, + withIntermediateDirectories: recursive, + attributes: [:] + ) + } + + func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws { + let destString = relative ? destination.relative(to: path.parentDirectory) + .pathString : destination.pathString + try FileManager.default.createSymbolicLink( + atPath: path.pathString, + withDestinationPath: destString + ) + } + + func data(_ path: AbsolutePath) throws -> Data { + try Data(contentsOf: URL(fileURLWithPath: path.pathString)) + } + + func readFileContents(_ path: AbsolutePath) throws -> ByteString { + // Open the file. + guard let fp = fopen(path.pathString, "rb") else { + throw FileSystemError(errno: errno, path) + } + defer { fclose(fp) } + + // Read the data one block at a time. + let data = BufferedOutputByteStream() + var tmpBuffer = [UInt8](repeating: 0, count: 1 << 12) + while true { + let n = fread(&tmpBuffer, 1, tmpBuffer.count, fp) + if n < 0 { + if errno == EINTR { continue } + throw FileSystemError(.ioError(code: errno), path) + } + if n == 0 { + let errno = ferror(fp) + if errno != 0 { + throw FileSystemError(.ioError(code: errno), path) + } + break + } + data.send(tmpBuffer[0..) throws { + guard exists(path) else { return } + func setMode(path: String) throws { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + // Skip if only files should be changed. + if options.contains(.onlyFiles) && attrs[.type] as? FileAttributeType != .typeRegular { + return + } + + // Compute the new mode for this file. + let currentMode = attrs[.posixPermissions] as! Int16 + let newMode = mode.setMode(currentMode) + guard newMode != currentMode else { return } + try FileManager.default.setAttributes( + [.posixPermissions: newMode], + ofItemAtPath: path + ) + } + + try setMode(path: path.pathString) + guard isDirectory(path) else { return } + + guard let traverse = FileManager.default.enumerator( + at: URL(fileURLWithPath: path.pathString), + includingPropertiesForKeys: nil + ) else { + throw FileSystemError(.noEntry, path) + } + + if !options.contains(.recursive) { + traverse.skipDescendants() + } + + while let path = traverse.nextObject() { + try setMode(path: (path as! URL).path) + } + } + + func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } + guard !exists(destinationPath) + else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } + try FileManager.default.copyItem(at: sourcePath.asURL, to: destinationPath.asURL) + } + + func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + guard exists(sourcePath) else { throw FileSystemError(.noEntry, sourcePath) } + guard !exists(destinationPath) + else { throw FileSystemError(.alreadyExistsAtDestination, destinationPath) } + try FileManager.default.moveItem(at: sourcePath.asURL, to: destinationPath.asURL) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + try FileLock.withLock(fileToLock: path, type: type, blocking: blocking, body: body) + } + + func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () async throws -> T + ) async throws -> T { + try await FileLock.withLock(fileToLock: path, type: type, blocking: blocking, body: body) + } + + func itemReplacementDirectories(for path: AbsolutePath) throws -> [AbsolutePath] { + let result = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: path.asURL, + create: false + ) + let path = try AbsolutePath(validating: result.path) + // Foundation returns a path that is unique every time, so we return both that path, as well + // as its parent. + return [path, path.parentDirectory] + } +} + +/// Concrete FileSystem implementation which simulates an empty disk. +public final class InMemoryFileSystem: FileSystem { + /// Private internal representation of a file system node. + /// Not thread-safe. + private class Node { + /// The actual node data. + let contents: NodeContents + + init(_ contents: NodeContents) { + self.contents = contents + } + + /// Creates deep copy of the object. + func copy() -> Node { + return Node(contents.copy()) + } + } + + /// Private internal representation the contents of a file system node. + /// Not thread-safe. + private enum NodeContents { + case file(ByteString) + case directory(DirectoryContents) + case symlink(String) + + /// Creates deep copy of the object. + func copy() -> NodeContents { + switch self { + case let .file(bytes): + return .file(bytes) + case let .directory(contents): + return .directory(contents.copy()) + case let .symlink(path): + return .symlink(path) + } + } + } + + /// Private internal representation the contents of a directory. + /// Not thread-safe. + private final class DirectoryContents { + var entries: [String: Node] + + init(entries: [String: Node] = [:]) { + self.entries = entries + } + + /// Creates deep copy of the object. + func copy() -> DirectoryContents { + let contents = DirectoryContents() + for (key, node) in entries { + contents.entries[key] = node.copy() + } + return contents + } + } + + /// The root node of the filesystem. + private var root: Node + + /// Protects `root` and everything underneath it. + /// FIXME: Using a single lock for this is a performance problem, but in + /// reality, the only practical use for InMemoryFileSystem is for unit + /// tests. + private let lock = NSLock() + /// A map that keeps weak references to all locked files. + private var lockFiles = [AbsolutePath: WeakReference]() + /// Used to access lockFiles in a thread safe manner. + private let lockFilesLock = NSLock() + + /// Exclusive file system lock vended to clients through `withLock()`. + /// Used to ensure that DispatchQueues are released when they are no longer in use. + private struct WeakReference { + weak var reference: Value? + + init(_ value: Value?) { + reference = value + } + } + + public init() { + root = Node(.directory(DirectoryContents())) + } + + /// Creates deep copy of the object. + public func copy() -> InMemoryFileSystem { + return lock.withLock { + let fs = InMemoryFileSystem() + fs.root = root.copy() + return fs + } + } + + /// Private function to look up the node corresponding to a path. + /// Not thread-safe. + private func getNode(_ path: AbsolutePath, followSymlink: Bool = true) throws -> Node? { + func getNodeInternal(_ path: AbsolutePath) throws -> Node? { + // If this is the root node, return it. + if path.isRoot { + return root + } + + // Otherwise, get the parent node. + guard let parent = try getNodeInternal(path.parentDirectory) else { + return nil + } + + // If we didn't find a directory, this is an error. + guard case let .directory(contents) = parent.contents else { + throw FileSystemError(.notDirectory, path.parentDirectory) + } + + // Return the directory entry. + let node = contents.entries[path.basename] + + switch node?.contents { + case .directory, .file: + return node + case let .symlink(destination): + let destination = try AbsolutePath( + validating: destination, + relativeTo: path.parentDirectory + ) + return followSymlink ? try getNodeInternal(destination) : node + case .none: + return nil + } + } + + // Get the node that corresponds to the path. + return try getNodeInternal(path) + } + + // MARK: FileSystem Implementation + + public func exists(_ path: AbsolutePath, followSymlink: Bool) -> Bool { + return lock.withLock { + do { + switch try getNode(path, followSymlink: followSymlink)?.contents { + case .file, .directory, .symlink: return true + case .none: return false + } + } catch { + return false + } + } + } + + public func isDirectory(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .directory? = try getNode(path)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isFile(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .file? = try getNode(path)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isSymlink(_ path: AbsolutePath) -> Bool { + return lock.withLock { + do { + if case .symlink? = try getNode(path, followSymlink: false)?.contents { + return true + } + return false + } catch { + return false + } + } + } + + public func isReadable(_ path: AbsolutePath) -> Bool { + exists(path) + } + + public func isWritable(_ path: AbsolutePath) -> Bool { + exists(path) + } + + public func isExecutableFile(_: AbsolutePath) -> Bool { + // FIXME: Always return false until in-memory implementation + // gets permission semantics. + return false + } + + /// Virtualized current working directory. + public var currentWorkingDirectory: AbsolutePath? { + return try? AbsolutePath(validating: "/") + } + + public func changeCurrentWorkingDirectory(to path: AbsolutePath) throws { + throw FileSystemError(.unsupported, path) + } + + public var homeDirectory: AbsolutePath { + get throws { + // FIXME: Maybe we should allow setting this when creating the fs. + return try AbsolutePath(validating: "/home/user") + } + } + + public var cachesDirectory: AbsolutePath? { + return try? homeDirectory.appending(component: "caches") + } + + public var tempDirectory: AbsolutePath { + get throws { + return try AbsolutePath(validating: "/tmp") + } + } + + public func _getDirectoryContents( + _ path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]?, + options: FileManager.DirectoryEnumerationOptions + ) throws -> [AbsolutePath] { + return try lock.withLock { + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + guard case let .directory(contents) = node.contents else { + throw FileSystemError(.notDirectory, path) + } + + // FIXME: Perhaps we should change the protocol to allow lazy behavior. + return [String](contents.entries.keys).map { + path.appending(component: $0) + } + } + } + + /// Not thread-safe. + private func _createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + // Ignore if client passes root. + guard !path.isRoot else { + return + } + // Get the parent directory node. + let parentPath = path.parentDirectory + guard let parent = try getNode(parentPath) else { + // If the parent doesn't exist, and we are recursive, then attempt + // to create the parent and retry. + if recursive && path != parentPath { + // Attempt to create the parent. + try _createDirectory(parentPath, recursive: true) + + // Re-attempt creation, non-recursively. + return try _createDirectory(path, recursive: false) + } else { + // Otherwise, we failed. + throw FileSystemError(.noEntry, parentPath) + } + } + + // Check that the parent is a directory. + guard case let .directory(contents) = parent.contents else { + // The parent isn't a directory, this is an error. + throw FileSystemError(.notDirectory, parentPath) + } + + // Check if the node already exists. + if let node = contents.entries[path.basename] { + // Verify it is a directory. + guard case .directory = node.contents else { + // The path itself isn't a directory, this is an error. + throw FileSystemError(.notDirectory, path) + } + + // We are done. + return + } + + // Otherwise, the node does not exist, create it. + contents.entries[path.basename] = Node(.directory(DirectoryContents())) + } + + public func createDirectory(_ path: AbsolutePath, recursive: Bool) throws { + return try lock.withLock { + try _createDirectory(path, recursive: recursive) + } + } + + public func createSymbolicLink( + _ path: AbsolutePath, + pointingAt destination: AbsolutePath, + relative: Bool + ) throws { + return try lock.withLock { + // Create directory to destination parent. + guard let destinationParent = try getNode(path.parentDirectory) else { + throw FileSystemError(.noEntry, path.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = destinationParent.contents else { + throw FileSystemError(.notDirectory, path.parentDirectory) + } + + guard contents.entries[path.basename] == nil else { + throw FileSystemError(.alreadyExistsAtDestination, path) + } + + let destination = relative ? destination.relative(to: path.parentDirectory) + .pathString : destination.pathString + + contents.entries[path.basename] = Node(.symlink(destination)) + } + } + + public func data(_ path: AbsolutePath) throws -> Data { + return try lock.withLock { + // Get the node. + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + + // Check that the node is a file. + guard case let .file(contents) = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + + // Return the file contents. + return contents.withData { $0 } + } + } + + public func readFileContents(_ path: AbsolutePath) throws -> ByteString { + return try lock.withLock { + // Get the node. + guard let node = try getNode(path) else { + throw FileSystemError(.noEntry, path) + } + + // Check that the node is a file. + guard case let .file(contents) = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + + // Return the file contents. + return contents + } + } + + public func writeFileContents(_ path: AbsolutePath, bytes: ByteString) throws { + return try lock.withLock { + // It is an error if this is the root node. + let parentPath = path.parentDirectory + guard path != parentPath else { + throw FileSystemError(.isDirectory, path) + } + + // Get the parent node. + guard let parent = try getNode(parentPath) else { + throw FileSystemError(.noEntry, parentPath) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = parent.contents else { + // The parent isn't a directory, this is an error. + throw FileSystemError(.notDirectory, parentPath) + } + + // Check if the node exists. + if let node = contents.entries[path.basename] { + // Verify it is a file. + guard case .file = node.contents else { + // The path is a directory, this is an error. + throw FileSystemError(.isDirectory, path) + } + } + + // Write the file. + contents.entries[path.basename] = Node(.file(bytes)) + } + } + + public func writeFileContents( + _ path: AbsolutePath, + bytes: ByteString, + atomically: Bool + ) throws { + // In memory file system's writeFileContents is already atomic, so ignore the parameter here + // and just call the base implementation. + try writeFileContents(path, bytes: bytes) + } + + public func removeFileTree(_ path: AbsolutePath) throws { + return lock.withLock { + // Ignore root and get the parent node's content if its a directory. + guard !path.isRoot, + let parent = try? getNode(path.parentDirectory), + case let .directory(contents) = parent.contents + else { + return + } + // Set it to nil to release the contents. + contents.entries[path.basename] = nil + } + } + + public func chmod(_ mode: FileMode, path: AbsolutePath, options: Set) throws { + // FIXME: We don't have these semantics in InMemoryFileSystem. + } + + /// Private implementation of core copying function. + /// Not thread-safe. + private func _copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + // Get the source node. + guard let source = try getNode(sourcePath) else { + throw FileSystemError(.noEntry, sourcePath) + } + + // Create directory to destination parent. + guard let destinationParent = try getNode(destinationPath.parentDirectory) else { + throw FileSystemError(.noEntry, destinationPath.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = destinationParent.contents else { + throw FileSystemError(.notDirectory, destinationPath.parentDirectory) + } + + guard contents.entries[destinationPath.basename] == nil else { + throw FileSystemError(.alreadyExistsAtDestination, destinationPath) + } + + contents.entries[destinationPath.basename] = source + } + + public func copy(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + return try lock.withLock { + try _copy(from: sourcePath, to: destinationPath) + } + } + + public func move(from sourcePath: AbsolutePath, to destinationPath: AbsolutePath) throws { + return try lock.withLock { + // Get the source parent node. + guard let sourceParent = try getNode(sourcePath.parentDirectory) else { + throw FileSystemError(.noEntry, sourcePath.parentDirectory) + } + + // Check that the parent is a directory. + guard case let .directory(contents) = sourceParent.contents else { + throw FileSystemError(.notDirectory, sourcePath.parentDirectory) + } + + try _copy(from: sourcePath, to: destinationPath) + + contents.entries[sourcePath.basename] = nil + } + } + + public func withLock( + on path: AbsolutePath, + type: FileLock.LockType, + blocking: Bool, + _ body: () throws -> T + ) throws -> T { + if !blocking { + throw FileSystemError(.unsupported, path) + } + + let resolvedPath: AbsolutePath = try lock.withLock { + if case let .symlink(destination) = try getNode(path)?.contents { + return try AbsolutePath(validating: destination, relativeTo: path.parentDirectory) + } else { + return path + } + } + + let fileQueue: DispatchQueue = lockFilesLock.withLock { + if let queueReference = lockFiles[resolvedPath], let queue = queueReference.reference { + return queue + } else { + let queue = DispatchQueue( + label: "org.swift.swiftpm.in-memory-file-system.file-queue", + attributes: .concurrent + ) + lockFiles[resolvedPath] = WeakReference(queue) + return queue + } + } + + return try fileQueue.sync(flags: type == .exclusive ? .barrier : .init(), execute: body) + } +} + +// Internal state of `InMemoryFileSystem` is protected with a lock in all of its `public` methods. +#if compiler(>=5.7) +extension InMemoryFileSystem: @unchecked Sendable {} +#else +extension InMemoryFileSystem: UnsafeSendable {} +#endif + +private var _localFileSystem: FileSystem = LocalFileSystem() + +/// Public access to the local FS proxy. +public var localFileSystem: FileSystem { + return _localFileSystem +} + +public extension FileSystem { + /// Print the filesystem tree of the given path. + /// + /// For debugging only. + func dumpTree(at path: AbsolutePath = .root) { + print(".") + do { + try recurse(fs: self, path: path) + } catch { + print("\(error)") + } + } + + /// Write bytes to the path if the given contents are different. + func writeIfChanged(path: AbsolutePath, bytes: ByteString) throws { + try createDirectory(path.parentDirectory, recursive: true) + + // Return if the contents are same. + if isFile(path), try readFileContents(path) == bytes { + return + } + + try writeFileContents(path, bytes: bytes) + } + + func getDirectoryContents( + at path: AbsolutePath, + includingPropertiesForKeys: [URLResourceKey]? = nil, + options: FileManager.DirectoryEnumerationOptions = [] + ) throws -> [AbsolutePath] { + return try _getDirectoryContents( + path, + includingPropertiesForKeys: includingPropertiesForKeys, + options: options + ) + } + + /// Helper method to recurse and print the tree. + private func recurse(fs: FileSystem, path: AbsolutePath, prefix: String = "") throws { + let contents = (try fs.getDirectoryContents(at: path)).map(\.basename) + + for (idx, entry) in contents.enumerated() { + let isLast = idx == contents.count - 1 + let line = prefix + (isLast ? "โ””โ”€โ”€ " : "โ”œโ”€โ”€ ") + entry + print(line) + + let entryPath = path.appending(component: entry) + if fs.isDirectory(entryPath) { + let childPrefix = prefix + (isLast ? " " : "โ”‚ ") + try recurse(fs: fs, path: entryPath, prefix: String(childPrefix)) + } + } + } +} + diff --git a/Tool/Sources/FileSystem/Lock.swift b/Tool/Sources/FileSystem/Lock.swift new file mode 100644 index 0000000..695494a --- /dev/null +++ b/Tool/Sources/FileSystem/Lock.swift @@ -0,0 +1,214 @@ +import Foundation + +public enum ProcessLockError: Error { + case unableToAquireLock(errno: Int32) +} + +extension ProcessLockError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: "\(self)"] + } +} + +/// Provides functionality to acquire a lock on a file via POSIX's flock() method. +/// It can be used for things like serializing concurrent mutations on a shared resource +/// by multiple instances of a process. The `FileLock` is not thread-safe. +public final class FileLock { + + public enum LockType { + case exclusive + case shared + } + + /// File descriptor to the lock file. + #if os(Windows) + private var handle: HANDLE? + #else + private var fileDescriptor: CInt? + #endif + + /// Path to the lock file. + private let lockFile: AbsolutePath + + /// Create an instance of FileLock at the path specified + /// + /// Note: The parent directory path should be a valid directory. + internal init(at lockFile: AbsolutePath) { + self.lockFile = lockFile + } + + @available(*, deprecated, message: "use init(at:) instead") + public convenience init(name: String, cachePath: AbsolutePath) { + self.init(at: cachePath.appending(component: name + ".lock")) + } + + /// Try to acquire a lock. This method will block until lock the already aquired by other process. + /// + /// Note: This method can throw if underlying POSIX methods fail. + public func lock(type: LockType = .exclusive, blocking: Bool = true) throws { + #if os(Windows) + if handle == nil { + let h: HANDLE = lockFile.pathString.withCString(encodedAs: UTF16.self, { + CreateFileW( + $0, + UInt32(GENERIC_READ) | UInt32(GENERIC_WRITE), + UInt32(FILE_SHARE_READ) | UInt32(FILE_SHARE_WRITE), + nil, + DWORD(OPEN_ALWAYS), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + }) + if h == INVALID_HANDLE_VALUE { + throw FileSystemError(errno: Int32(GetLastError()), lockFile) + } + self.handle = h + } + var overlapped = OVERLAPPED() + overlapped.Offset = 0 + overlapped.OffsetHigh = 0 + overlapped.hEvent = nil + var dwFlags = Int32(0) + switch type { + case .exclusive: dwFlags |= LOCKFILE_EXCLUSIVE_LOCK + case .shared: break + } + if !blocking { + dwFlags |= LOCKFILE_FAIL_IMMEDIATELY + } + if !LockFileEx(handle, DWORD(dwFlags), 0, + UInt32.max, UInt32.max, &overlapped) { + throw ProcessLockError.unableToAquireLock(errno: Int32(GetLastError())) + } + #else + // Open the lock file. + if fileDescriptor == nil { + let fd = open(lockFile.pathString, O_WRONLY | O_CREAT | O_CLOEXEC, 0o666) + if fd == -1 { + throw FileSystemError(errno: errno, lockFile) + } + self.fileDescriptor = fd + } + var flags = Int32(0) + switch type { + case .exclusive: flags = LOCK_EX + case .shared: flags = LOCK_SH + } + if !blocking { + flags |= LOCK_NB + } + // Aquire lock on the file. + while true { + if flock(fileDescriptor!, flags) == 0 { + break + } + // Retry if interrupted. + if errno == EINTR { continue } + throw ProcessLockError.unableToAquireLock(errno: errno) + } + #endif + } + + /// Unlock the held lock. + public func unlock() { + #if os(Windows) + var overlapped = OVERLAPPED() + overlapped.Offset = 0 + overlapped.OffsetHigh = 0 + overlapped.hEvent = nil + UnlockFileEx(handle, 0, UInt32.max, UInt32.max, &overlapped) + #else + guard let fd = fileDescriptor else { return } + flock(fd, LOCK_UN) + #endif + } + + deinit { + #if os(Windows) + guard let handle = handle else { return } + CloseHandle(handle) + #else + guard let fd = fileDescriptor else { return } + close(fd) + #endif + } + + /// Execute the given block while holding the lock. + public func withLock(type: LockType = .exclusive, blocking: Bool = true, _ body: () throws -> T) throws -> T { + try lock(type: type, blocking: blocking) + defer { unlock() } + return try body() + } + + /// Execute the given block while holding the lock. + public func withLock(type: LockType = .exclusive, blocking: Bool = true, _ body: () async throws -> T) async throws -> T { + try lock(type: type, blocking: blocking) + defer { unlock() } + return try await body() + } + + public static func prepareLock( + fileToLock: AbsolutePath, + at lockFilesDirectory: AbsolutePath? = nil + ) throws -> FileLock { + // unless specified, we use the tempDirectory to store lock files + let lockFilesDirectory = try lockFilesDirectory ?? localFileSystem.tempDirectory + if !localFileSystem.exists(lockFilesDirectory) { + throw FileSystemError(.noEntry, lockFilesDirectory) + } + if !localFileSystem.isDirectory(lockFilesDirectory) { + throw FileSystemError(.notDirectory, lockFilesDirectory) + } + // use the parent path to generate unique filename in temp + var lockFileName = try (resolveSymlinks(fileToLock.parentDirectory) + .appending(component: fileToLock.basename)) + .components.joined(separator: "_") + .replacingOccurrences(of: ":", with: "_") + ".lock" +#if os(Windows) + // NTFS has an ARC limit of 255 codepoints + var lockFileUTF16 = lockFileName.utf16.suffix(255) + while String(lockFileUTF16) == nil { + lockFileUTF16 = lockFileUTF16.dropFirst() + } + lockFileName = String(lockFileUTF16) ?? lockFileName +#else + if lockFileName.hasPrefix(AbsolutePath.root.pathString) { + lockFileName = String(lockFileName.dropFirst(AbsolutePath.root.pathString.count)) + } + // back off until it occupies at most `NAME_MAX` UTF-8 bytes but without splitting scalars + // (we might split clusters but it's not worth the effort to keep them together as long as we get a valid file name) + var lockFileUTF8 = lockFileName.utf8.suffix(Int(NAME_MAX)) + while String(lockFileUTF8) == nil { + // in practice this will only be a few iterations + lockFileUTF8 = lockFileUTF8.dropFirst() + } + // we will never end up with nil since we have ASCII characters at the end + lockFileName = String(lockFileUTF8) ?? lockFileName +#endif + let lockFilePath = lockFilesDirectory.appending(component: lockFileName) + + return FileLock(at: lockFilePath) + } + + public static func withLock( + fileToLock: AbsolutePath, + lockFilesDirectory: AbsolutePath? = nil, + type: LockType = .exclusive, + blocking: Bool = true, + body: () throws -> T + ) throws -> T { + let lock = try Self.prepareLock(fileToLock: fileToLock, at: lockFilesDirectory) + return try lock.withLock(type: type, blocking: blocking, body) + } + + public static func withLock( + fileToLock: AbsolutePath, + lockFilesDirectory: AbsolutePath? = nil, + type: LockType = .exclusive, + blocking: Bool = true, + body: () async throws -> T + ) async throws -> T { + let lock = try Self.prepareLock(fileToLock: fileToLock, at: lockFilesDirectory) + return try await lock.withLock(type: type, blocking: blocking, body) + } +} diff --git a/Tool/Sources/FileSystem/Misc.swift b/Tool/Sources/FileSystem/Misc.swift new file mode 100644 index 0000000..f016cfd --- /dev/null +++ b/Tool/Sources/FileSystem/Misc.swift @@ -0,0 +1,426 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// `CStringArray` represents a C null-terminated array of pointers to C strings. +/// +/// The lifetime of the C strings will correspond to the lifetime of the `CStringArray` +/// instance so be careful about copying the buffer as it may contain dangling pointers. +public final class CStringArray { + /// The null-terminated array of C string pointers. + public let cArray: [UnsafeMutablePointer?] + + /// Creates an instance from an array of strings. + public init(_ array: [String]) { +#if os(Windows) + cArray = array.map({ $0.withCString({ _strdup($0) }) }) + [nil] +#else + cArray = array.map({ $0.withCString({ strdup($0) }) }) + [nil] +#endif + } + + deinit { + for case let element? in cArray { + free(element) + } + } +} + +import Foundation +#if os(Windows) +import WinSDK +#endif + +#if os(Windows) +public let executableFileSuffix = ".exe" +#else +public let executableFileSuffix = "" +#endif + +#if os(Windows) +private func quote(_ arguments: [String]) -> String { + func quote(argument: String) -> String { + if !argument.contains(where: { " \t\n\"".contains($0) }) { + return argument + } + + // To escape the command line, we surround the argument with quotes. + // However, the complication comes due to how the Windows command line + // parser treats backslashes (\) and quotes ("). + // + // - \ is normally treated as a literal backslash + // e.g. alpha\beta\gamma => alpha\beta\gamma + // - The sequence \" is treated as a literal " + // e.g. alpha\"beta => alpha"beta + // + // But then what if we are given a path that ends with a \? + // + // Surrounding alpha\beta\ with " would be "alpha\beta\" which would be + // an unterminated string since it ends on a literal quote. To allow + // this case the parser treats: + // + // - \\" as \ followed by the " metacharacter + // - \\\" as \ followed by a literal " + // + // In general: + // - 2n \ followed by " => n \ followed by the " metacharacter + // - 2n + 1 \ followed by " => n \ followed by a literal " + + var quoted = "\"" + var unquoted = argument.unicodeScalars + + while !unquoted.isEmpty { + guard let firstNonBS = unquoted.firstIndex(where: { $0 != "\\" }) else { + // String ends with a backslash (e.g. first\second\), escape all + // the backslashes then add the metacharacter ". + let count = unquoted.count + quoted.append(String(repeating: "\\", count: 2 * count)) + break + } + + let count = unquoted.distance(from: unquoted.startIndex, to: firstNonBS) + if unquoted[firstNonBS] == "\"" { + // This is a string of \ followed by a " (e.g. first\"second). + // Escape the backslashes and the quote. + quoted.append(String(repeating: "\\", count: 2 * count + 1)) + } else { + // These are just literal backslashes + quoted.append(String(repeating: "\\", count: count)) + } + + quoted.append(String(unquoted[firstNonBS])) + + // Drop the backslashes and the following character + unquoted.removeFirst(count + 1) + } + quoted.append("\"") + + return quoted + } + return arguments.map(quote(argument:)).joined(separator: " ") +} +#endif + +/// Replace the current process image with a new process image. +/// +/// - Parameters: +/// - path: Absolute path to the executable. +/// - args: The executable arguments. +public func exec(path: String, args: [String]) throws -> Never { + let cArgs = CStringArray(args) + #if os(Windows) + var hJob: HANDLE + + hJob = CreateJobObjectA(nil, nil) + if hJob == HANDLE(bitPattern: 0) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + defer { CloseHandle(hJob) } + + let hPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, nil, 0, 1) + if hPort == HANDLE(bitPattern: 0) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + var acpAssociation: JOBOBJECT_ASSOCIATE_COMPLETION_PORT = JOBOBJECT_ASSOCIATE_COMPLETION_PORT() + acpAssociation.CompletionKey = hJob + acpAssociation.CompletionPort = hPort + if !SetInformationJobObject(hJob, JobObjectAssociateCompletionPortInformation, + &acpAssociation, DWORD(MemoryLayout.size)) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + var eliLimits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + eliLimits.BasicLimitInformation.LimitFlags = + DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) | DWORD(JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) + if !SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &eliLimits, + DWORD(MemoryLayout.size)) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + + var siInfo: STARTUPINFOW = STARTUPINFOW() + siInfo.cb = DWORD(MemoryLayout.size) + + var piInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + + try quote(args).withCString(encodedAs: UTF16.self) { pwszCommandLine in + if !CreateProcessW(nil, + UnsafeMutablePointer(mutating: pwszCommandLine), + nil, nil, false, + DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP), + nil, nil, &siInfo, &piInfo) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + } + + defer { CloseHandle(piInfo.hThread) } + defer { CloseHandle(piInfo.hProcess) } + + if !AssignProcessToJobObject(hJob, piInfo.hProcess) { + throw SystemError.exec(Int32(GetLastError()), path: path, args: args) + } + + _ = ResumeThread(piInfo.hThread) + + var dwCompletionCode: DWORD = 0 + var ulCompletionKey: ULONG_PTR = 0 + var lpOverlapped: LPOVERLAPPED? + repeat { + } while GetQueuedCompletionStatus(hPort, &dwCompletionCode, &ulCompletionKey, + &lpOverlapped, INFINITE) && + !(ulCompletionKey == ULONG_PTR(UInt(bitPattern: hJob)) && + dwCompletionCode == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) + + var dwExitCode: DWORD = DWORD(bitPattern: -1) + _ = GetExitCodeProcess(piInfo.hProcess, &dwExitCode) + _exit(Int32(bitPattern: dwExitCode)) + #elseif (!canImport(Darwin) || os(macOS)) + guard execv(path, cArgs.cArray) != -1 else { + throw SystemError.exec(errno, path: path, args: args) + } + fatalError("unreachable") + #else + fatalError("not implemented") + #endif +} + +@_disfavoredOverload +@available(*, deprecated, message: "Use the overload which returns Never") +public func exec(path: String, args: [String]) throws { + try exec(path: path, args: args) +} + +// MARK: TSCUtility function for searching for executables + +/// Create a list of AbsolutePath search paths from a string, such as the PATH environment variable. +/// +/// - Parameters: +/// - pathString: The path string to parse. +/// - currentWorkingDirectory: The current working directory, the relative paths will be converted to absolute paths +/// based on this path. +/// - Returns: List of search paths. +public func getEnvSearchPaths( + pathString: String?, + currentWorkingDirectory: AbsolutePath? +) -> [AbsolutePath] { + // Compute search paths from PATH variable. +#if os(Windows) + let pathSeparator: Character = ";" +#else + let pathSeparator: Character = ":" +#endif + return (pathString ?? "").split(separator: pathSeparator).map(String.init).compactMap({ pathString in + if let cwd = currentWorkingDirectory { + return try? AbsolutePath(validating: pathString, relativeTo: cwd) + } + return try? AbsolutePath(validating: pathString) + }) +} + +/// Lookup an executable path from an environment variable value, current working +/// directory or search paths. Only return a value that is both found and executable. +/// +/// This method searches in the following order: +/// * If env value is a valid absolute path, return it. +/// * If env value is relative path, first try to locate it in current working directory. +/// * Otherwise, in provided search paths. +/// +/// - Parameters: +/// - filename: The name of the file to find. +/// - currentWorkingDirectory: The current working directory to look in. +/// - searchPaths: The additional search paths to look in if not found in cwd. +/// - Returns: Valid path to executable if present, otherwise nil. +public func lookupExecutablePath( + filename value: String?, + currentWorkingDirectory: AbsolutePath? = localFileSystem.currentWorkingDirectory, + searchPaths: [AbsolutePath] = [] +) -> AbsolutePath? { + + // We should have a value to continue. + guard let value = value, !value.isEmpty else { + return nil + } + + var paths: [AbsolutePath] = [] + + if let cwd = currentWorkingDirectory, let path = try? AbsolutePath(validating: value, relativeTo: cwd) { + // We have a value, but it could be an absolute or a relative path. + paths.append(path) + } else if let absPath = try? AbsolutePath(validating: value) { + // Current directory not being available is not a problem + // for the absolute-specified paths. + paths.append(absPath) + } + + // Ensure the value is not a path. + if !value.contains("/") { + // Try to locate in search paths. + paths.append(contentsOf: searchPaths.map({ $0.appending(component: value) })) + } + + return paths.first(where: { localFileSystem.isExecutableFile($0) }) +} + +/// A wrapper for Range to make it Codable. +/// +/// Technically, we can use conditional conformance and make +/// stdlib's Range Codable but since extensions leak out, it +/// is not a good idea to extend types that you don't own. +/// +/// Range conformance will be added soon to stdlib so we can remove +/// this type in the future. +public struct CodableRange where Bound: Comparable & Codable { + + /// The underlying range. + public let range: Range + + /// Create a CodableRange instance. + public init(_ range: Range) { + self.range = range + } +} + +extension CodableRange: Sendable where Bound: Sendable {} + +extension CodableRange: Codable { + private enum CodingKeys: String, CodingKey { + case lowerBound, upperBound + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(range.lowerBound, forKey: .lowerBound) + try container.encode(range.upperBound, forKey: .upperBound) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lowerBound = try container.decode(Bound.self, forKey: .lowerBound) + let upperBound = try container.decode(Bound.self, forKey: .upperBound) + self.init(Range(uncheckedBounds: (lowerBound, upperBound))) + } +} + +extension AbsolutePath { + /// File URL created from the normalized string representation of the path. + public var asURL: Foundation.URL { + return URL(fileURLWithPath: pathString) + } + + public init(_ url: URL) throws { + try self.init(validating: url.path) + } +} + +// FIXME: Eliminate or find a proper place for this. +public enum SystemError: Error { + case chdir(Int32, String) + case close(Int32) + case exec(Int32, path: String, args: [String]) + case pipe(Int32) + case posix_spawn(Int32, [String]) + case read(Int32) + case setenv(Int32, String) + case stat(Int32, String) + case symlink(Int32, String, dest: String) + case unsetenv(Int32, String) + case waitpid(Int32) +} + +extension SystemError: CustomStringConvertible { + public var description: String { + func strerror(_ errno: Int32) -> String { + #if os(Windows) + let cap = 128 + var buf = [Int8](repeating: 0, count: cap) + let _ = strerror_s(&buf, 128, errno) + return "\(String(cString: buf)) (\(errno))" + #else + var cap = 64 + while cap <= 16 * 1024 { + var buf = [Int8](repeating: 0, count: cap) + let err = strerror_r(errno, &buf, buf.count) + if err == EINVAL { + return "Unknown error \(errno)" + } + if err == ERANGE { + cap *= 2 + continue + } + if err != 0 { + fatalError("strerror_r error: \(err)") + } + return "\(String(cString: buf)) (\(errno))" + } + fatalError("strerror_r error: \(ERANGE)") + #endif + } + + switch self { + case .chdir(let errno, let path): + return "chdir error: \(strerror(errno)): \(path)" + case .close(let err): + let errorMessage: String + if err == -1 { // if the return code is -1, we need to consult the global `errno` + errorMessage = strerror(errno) + } else { + errorMessage = strerror(err) + } + return "close error: \(errorMessage)" + case .exec(let errno, let path, let args): + let joinedArgs = args.joined(separator: " ") + return "exec error: \(strerror(errno)): \(path) \(joinedArgs)" + case .pipe(let errno): + return "pipe error: \(strerror(errno))" + case .posix_spawn(let errno, let args): + return "posix_spawn error: \(strerror(errno)), `\(args)`" + case .read(let errno): + return "read error: \(strerror(errno))" + case .setenv(let errno, let key): + return "setenv error: \(strerror(errno)): \(key)" + case .stat(let errno, _): + return "stat error: \(strerror(errno))" + case .symlink(let errno, let path, let dest): + return "symlink error: \(strerror(errno)): \(path) -> \(dest)" + case .unsetenv(let errno, let key): + return "unsetenv error: \(strerror(errno)): \(key)" + case .waitpid(let errno): + return "waitpid error: \(strerror(errno))" + } + } +} + +extension SystemError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: self.description] + } +} + +/// Memoizes a costly computation to a cache variable. +func memoize(to cache: inout T?, build: () throws -> T) rethrows -> T { + if let value = cache { + return value + } else { + let value = try build() + cache = value + return value + } +} diff --git a/Tool/Sources/FileSystem/Path.swift b/Tool/Sources/FileSystem/Path.swift new file mode 100644 index 0000000..b65a22b --- /dev/null +++ b/Tool/Sources/FileSystem/Path.swift @@ -0,0 +1,1058 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2018 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ +#if os(Windows) +import Foundation +import WinSDK +#endif + +#if os(Windows) +private typealias PathImpl = WindowsPath +#else +private typealias PathImpl = UNIXPath +#endif + +import protocol Foundation.CustomNSError +import var Foundation.NSLocalizedDescriptionKey + +/// Represents an absolute file system path, independently of what (or whether +/// anything at all) exists at that path in the file system at any given time. +/// An absolute path always starts with a `/` character, and holds a normalized +/// string representation. This normalization is strictly syntactic, and does +/// not access the file system in any way. +/// +/// The absolute path string is normalized by: +/// - Collapsing `..` path components +/// - Removing `.` path components +/// - Removing any trailing path separator +/// - Removing any redundant path separators +/// +/// This string manipulation may change the meaning of a path if any of the +/// path components are symbolic links on disk. However, the file system is +/// never accessed in any way when initializing an AbsolutePath. +/// +/// Note that `~` (home directory resolution) is *not* done as part of path +/// normalization, because it is normally the responsibility of the shell and +/// not the program being invoked (e.g. when invoking `cd ~`, it is the shell +/// that evaluates the tilde; the `cd` command receives an absolute path). +public struct AbsolutePath: Hashable, Sendable { + /// Check if the given name is a valid individual path component. + /// + /// This only checks with regard to the semantics enforced by `AbsolutePath` + /// and `RelativePath`; particular file systems may have their own + /// additional requirements. + static func isValidComponent(_ name: String) -> Bool { + return PathImpl.isValidComponent(name) + } + + /// Private implementation details, shared with the RelativePath struct. + private let _impl: PathImpl + + /// Private initializer when the backing storage is known. + private init(_ impl: PathImpl) { + _impl = impl + } + + /// Initializes an AbsolutePath from a string that may be either absolute + /// or relative; if relative, `basePath` is used as the anchor; if absolute, + /// it is used as is, and in this case `basePath` is ignored. + public init(validating str: String, relativeTo basePath: AbsolutePath) throws { + if PathImpl(string: str).isAbsolute { + try self.init(validating: str) + } else { +#if os(Windows) + assert(!basePath.pathString.isEmpty) + guard !str.isEmpty else { + self.init(basePath._impl) + return + } + + let base: UnsafePointer = + basePath.pathString.fileSystemRepresentation + defer { base.deallocate() } + + let path: UnsafePointer = str.fileSystemRepresentation + defer { path.deallocate() } + + var pwszResult: PWSTR! + _ = String(cString: base).withCString(encodedAs: UTF16.self) { pwszBase in + String(cString: path).withCString(encodedAs: UTF16.self) { pwszPath in + PathAllocCombine(pwszBase, pwszPath, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &pwszResult) + } + } + defer { LocalFree(pwszResult) } + + self.init(String(decodingCString: pwszResult, as: UTF16.self)) +#else + try self.init(basePath, RelativePath(validating: str)) +#endif + } + } + + /// Initializes the AbsolutePath by concatenating a relative path to an + /// existing absolute path, and renormalizing if necessary. + public init(_ absPath: AbsolutePath, _ relPath: RelativePath) { + self.init(absPath._impl.appending(relativePath: relPath._impl)) + } + + /// Convenience initializer that appends a string to a relative path. + public init(_ absPath: AbsolutePath, validating relStr: String) throws { + try self.init(absPath, RelativePath(validating: relStr)) + } + + /// Initializes the AbsolutePath from `absStr`, which must be an absolute + /// path (i.e. it must begin with a path separator; this initializer does + /// not interpret leading `~` characters as home directory specifiers). + /// The input string will be normalized if needed, as described in the + /// documentation for AbsolutePath. + public init(validating path: String) throws { + try self.init(PathImpl(validatingAbsolutePath: path)) + } + + /// Directory component. An absolute path always has a non-empty directory + /// component (the directory component of the root path is the root itself). + public var dirname: String { + return _impl.dirname + } + + /// Last path component (including the suffix, if any). it is never empty. + public var basename: String { + return _impl.basename + } + + /// Returns the basename without the extension. + public var basenameWithoutExt: String { + if let ext = self.extension { + return String(basename.dropLast(ext.count + 1)) + } + return basename + } + + /// Suffix (including leading `.` character) if any. Note that a basename + /// that starts with a `.` character is not considered a suffix, nor is a + /// trailing `.` character. + public var suffix: String? { + return _impl.suffix + } + + /// Extension of the give path's basename. This follow same rules as + /// suffix except that it doesn't include leading `.` character. + public var `extension`: String? { + return _impl.extension + } + + /// Absolute path of parent directory. This always returns a path, because + /// every directory has a parent (the parent directory of the root directory + /// is considered to be the root directory itself). + public var parentDirectory: AbsolutePath { + return AbsolutePath(_impl.parentDirectory) + } + + /// True if the path is the root directory. + public var isRoot: Bool { + return _impl.isRoot + } + + /// Returns the absolute path with the relative path applied. + public func appending(_ subpath: RelativePath) -> AbsolutePath { + return AbsolutePath(self, subpath) + } + + /// Returns the absolute path with an additional literal component appended. + /// + /// This method accepts pseudo-path like '.' or '..', but should not contain "/". + public func appending(component: String) -> AbsolutePath { + return AbsolutePath(_impl.appending(component: component)) + } + + /// Returns the absolute path with additional literal components appended. + /// + /// This method should only be used in cases where the input is guaranteed + /// to be a valid path component (i.e., it cannot be empty, contain a path + /// separator, or be a pseudo-path like '.' or '..'). + public func appending(components names: [String]) -> AbsolutePath { + // FIXME: This doesn't seem a particularly efficient way to do this. + return names.reduce(self, { path, name in + path.appending(component: name) + }) + } + + public func appending(components names: String...) -> AbsolutePath { + appending(components: names) + } + + /// NOTE: We will most likely want to add other `appending()` methods, such + /// as `appending(suffix:)`, and also perhaps `replacing()` methods, + /// such as `replacing(suffix:)` or `replacing(basename:)` for some + /// of the more common path operations. + + /// NOTE: We may want to consider adding operators such as `+` for appending + /// a path component. + + /// NOTE: We will want to add a method to return the lowest common ancestor + /// path. + + /// Root directory (whose string representation is just a path separator). + public static let root = AbsolutePath(PathImpl.root) + + /// Normalized string representation (the normalization rules are described + /// in the documentation of the initializer). This string is never empty. + public var pathString: String { + return _impl.string + } + + /// Returns an array of strings that make up the path components of the + /// absolute path. This is the same sequence of strings as the basenames + /// of each successive path component, starting from the root. Therefore + /// the first path component of an absolute path is always `/`. + public var components: [String] { + return _impl.components + } +} + +/// Represents a relative file system path. A relative path never starts with +/// a `/` character, and holds a normalized string representation. As with +/// AbsolutePath, the normalization is strictly syntactic, and does not access +/// the file system in any way. +/// +/// The relative path string is normalized by: +/// - Collapsing `..` path components that aren't at the beginning +/// - Removing extraneous `.` path components +/// - Removing any trailing path separator +/// - Removing any redundant path separators +/// - Replacing a completely empty path with a `.` +/// +/// This string manipulation may change the meaning of a path if any of the +/// path components are symbolic links on disk. However, the file system is +/// never accessed in any way when initializing a RelativePath. +public struct RelativePath: Hashable, Sendable { + /// Private implementation details, shared with the AbsolutePath struct. + fileprivate let _impl: PathImpl + + /// Private initializer when the backing storage is known. + private init(_ impl: PathImpl) { + _impl = impl + } + + /// Convenience initializer that verifies that the path is relative. + public init(validating path: String) throws { + try self.init(PathImpl(validatingRelativePath: path)) + } + + /// Directory component. For a relative path without any path separators, + /// this is the `.` string instead of the empty string. + public var dirname: String { + return _impl.dirname + } + + /// Last path component (including the suffix, if any). It is never empty. + public var basename: String { + return _impl.basename + } + + /// Returns the basename without the extension. + public var basenameWithoutExt: String { + if let ext = self.extension { + return String(basename.dropLast(ext.count + 1)) + } + return basename + } + + /// Suffix (including leading `.` character) if any. Note that a basename + /// that starts with a `.` character is not considered a suffix, nor is a + /// trailing `.` character. + public var suffix: String? { + return _impl.suffix + } + + /// Extension of the give path's basename. This follow same rules as + /// suffix except that it doesn't include leading `.` character. + public var `extension`: String? { + return _impl.extension + } + + /// Normalized string representation (the normalization rules are described + /// in the documentation of the initializer). This string is never empty. + public var pathString: String { + return _impl.string + } + + /// Returns an array of strings that make up the path components of the + /// relative path. This is the same sequence of strings as the basenames + /// of each successive path component. Therefore the returned array of + /// path components is never empty; even an empty path has a single path + /// component: the `.` string. + public var components: [String] { + return _impl.components + } + + /// Returns the relative path with the given relative path applied. + public func appending(_ subpath: RelativePath) -> RelativePath { + return RelativePath(_impl.appending(relativePath: subpath._impl)) + } + + /// Returns the relative path with an additional literal component appended. + /// + /// This method accepts pseudo-path like '.' or '..', but should not contain "/". + public func appending(component: String) -> RelativePath { + return RelativePath(_impl.appending(component: component)) + } + + /// Returns the relative path with additional literal components appended. + /// + /// This method should only be used in cases where the input is guaranteed + /// to be a valid path component (i.e., it cannot be empty, contain a path + /// separator, or be a pseudo-path like '.' or '..'). + public func appending(components names: [String]) -> RelativePath { + // FIXME: This doesn't seem a particularly efficient way to do this. + return names.reduce(self, { path, name in + path.appending(component: name) + }) + } + + public func appending(components names: String...) -> RelativePath { + appending(components: names) + } +} + +extension AbsolutePath: Codable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(pathString) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } +} + +extension RelativePath: Codable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(pathString) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } +} + +// Make absolute paths Comparable. +extension AbsolutePath: Comparable { + public static func < (lhs: AbsolutePath, rhs: AbsolutePath) -> Bool { + return lhs.pathString < rhs.pathString + } +} + +/// Make absolute paths CustomStringConvertible and CustomDebugStringConvertible. +extension AbsolutePath: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + return pathString + } + + public var debugDescription: String { + // FIXME: We should really be escaping backslashes and quotes here. + return "" + } +} + +/// Make relative paths CustomStringConvertible and CustomDebugStringConvertible. +extension RelativePath: CustomStringConvertible { + public var description: String { + return _impl.string + } + + public var debugDescription: String { + // FIXME: We should really be escaping backslashes and quotes here. + return "" + } +} + +/// Private implementation shared between AbsolutePath and RelativePath. +protocol Path: Hashable { + + /// Root directory. + static var root: Self { get } + + /// Checks if a string is a valid component. + static func isValidComponent(_ name: String) -> Bool + + /// Normalized string of the (absolute or relative) path. Never empty. + var string: String { get } + + /// Returns whether the path is the root path. + var isRoot: Bool { get } + + /// Returns whether the path is an absolute path. + var isAbsolute: Bool { get } + + /// Returns the directory part of the stored path (relying on the fact that it has been normalized). Returns a + /// string consisting of just `.` if there is no directory part (which is the case if and only if there is no path + /// separator). + var dirname: String { get } + + /// Returns the last past component. + var basename: String { get } + + /// Returns the components of the path between each path separator. + var components: [String] { get } + + /// Path of parent directory. This always returns a path, because every directory has a parent (the parent + /// directory of the root directory is considered to be the root directory itself). + var parentDirectory: Self { get } + + /// Creates a path from its normalized string representation. + init(string: String) + + /// Creates a path from a string representation, validates that it is a valid absolute path and normalizes it. + init(validatingAbsolutePath: String) throws + + /// Creates a path from a string representation, validates that it is a valid relative path and normalizes it. + init(validatingRelativePath: String) throws + + /// Returns suffix with leading `.` if withDot is true otherwise without it. + func suffix(withDot: Bool) -> String? + + /// Returns a new Path by appending the path component. + func appending(component: String) -> Self + + /// Returns a path by concatenating a relative path and renormalizing if necessary. + func appending(relativePath: Self) -> Self +} + +extension Path { + var suffix: String? { + return suffix(withDot: true) + } + + var `extension`: String? { + return suffix(withDot: false) + } +} + +#if os(Windows) +private struct WindowsPath: Path, Sendable { + let string: String + + // NOTE: this is *NOT* a root path. It is a drive-relative path that needs + // to be specified due to assumptions in the APIs. Use the platform + // specific path separator as we should be normalizing the path normally. + // This is required to make the `InMemoryFileSystem` correctly iterate + // paths. + static let root = Self(string: "\\") + + static func isValidComponent(_ name: String) -> Bool { + return name != "" && name != "." && name != ".." && !name.contains("/") + } + + static func isAbsolutePath(_ path: String) -> Bool { + return !path.withCString(encodedAs: UTF16.self, PathIsRelativeW) + } + + var dirname: String { + let fsr: UnsafePointer = self.string.fileSystemRepresentation + defer { fsr.deallocate() } + + let path: String = String(cString: fsr) + return path.withCString(encodedAs: UTF16.self) { + let data = UnsafeMutablePointer(mutating: $0) + PathCchRemoveFileSpec(data, path.count) + return String(decodingCString: data, as: UTF16.self) + } + } + + var isAbsolute: Bool { + return Self.isAbsolutePath(self.string) + } + + public var isRoot: Bool { + return self.string.withCString(encodedAs: UTF16.self, PathCchIsRoot) + } + + var basename: String { + let path: String = self.string + return path.withCString(encodedAs: UTF16.self) { + PathStripPathW(UnsafeMutablePointer(mutating: $0)) + return String(decodingCString: $0, as: UTF16.self) + } + } + + // FIXME: We should investigate if it would be more efficient to instead + // return a path component iterator that does all its work lazily, moving + // from one path separator to the next on-demand. + // + var components: [String] { + let normalized: UnsafePointer = string.fileSystemRepresentation + defer { normalized.deallocate() } + + return String(cString: normalized).components(separatedBy: "\\").filter { !$0.isEmpty } + } + + var parentDirectory: Self { + return self == .root ? self : Self(string: dirname) + } + + init(string: String) { + if string.first?.isASCII ?? false, string.first?.isLetter ?? false, string.first?.isLowercase ?? false, + string.count > 1, string[string.index(string.startIndex, offsetBy: 1)] == ":" + { + self.string = "\(string.first!.uppercased())\(string.dropFirst(1))" + } else { + self.string = string + } + } + + private static func repr(_ path: String) -> String { + guard !path.isEmpty else { return "" } + let representation: UnsafePointer = path.fileSystemRepresentation + defer { representation.deallocate() } + return String(cString: representation) + } + + init(validatingAbsolutePath path: String) throws { + let realpath = Self.repr(path) + if !Self.isAbsolutePath(realpath) { + throw PathValidationError.invalidAbsolutePath(path) + } + self.init(string: realpath) + } + + init(validatingRelativePath path: String) throws { + if path.isEmpty || path == "." { + self.init(string: ".") + } else { + let realpath: String = Self.repr(path) + // Treat a relative path as an invalid relative path... + if Self.isAbsolutePath(realpath) || realpath.first == "\\" { + throw PathValidationError.invalidRelativePath(path) + } + self.init(string: realpath) + } + } + + func suffix(withDot: Bool) -> String? { + return self.string.withCString(encodedAs: UTF16.self) { + if let pointer = PathFindExtensionW($0) { + let substring = String(decodingCString: pointer, as: UTF16.self) + guard substring.length > 0 else { return nil } + return withDot ? substring : String(substring.dropFirst(1)) + } + return nil + } + } + + func appending(component name: String) -> Self { + var result: PWSTR? + _ = string.withCString(encodedAs: UTF16.self) { root in + name.withCString(encodedAs: UTF16.self) { path in + PathAllocCombine(root, path, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &result) + } + } + defer { LocalFree(result) } + return Self(string: String(decodingCString: result!, as: UTF16.self)) + } + + func appending(relativePath: Self) -> Self { + var result: PWSTR? + _ = string.withCString(encodedAs: UTF16.self) { root in + relativePath.string.withCString(encodedAs: UTF16.self) { path in + PathAllocCombine(root, path, ULONG(PATHCCH_ALLOW_LONG_PATHS.rawValue), &result) + } + } + defer { LocalFree(result) } + return Self(string: String(decodingCString: result!, as: UTF16.self)) + } +} +#else +private struct UNIXPath: Path, Sendable { + let string: String + + static let root = Self(string: "/") + + static func isValidComponent(_ name: String) -> Bool { + return name != "" && name != "." && name != ".." && !name.contains("/") + } + + var dirname: String { + // FIXME: This method seems too complicated; it should be simplified, + // if possible, and certainly optimized (using UTF8View). + // Find the last path separator. + guard let idx = string.lastIndex(of: "/") else { + // No path separators, so the directory name is `.`. + return "." + } + // Check if it's the only one in the string. + if idx == string.startIndex { + // Just one path separator, so the directory name is `/`. + return "/" + } + // Otherwise, it's the string up to (but not including) the last path + // separator. + return String(string.prefix(upTo: idx)) + } + + var isAbsolute: Bool { + return string.hasPrefix("/") + } + + var isRoot: Bool { + return self == Self.root + } + + var basename: String { + // Find the last path separator. + guard let idx = string.lastIndex(of: "/") else { + // No path separators, so the basename is the whole string. + return string + } + // Otherwise, it's the string from (but not including) the last path + // separator. + return String(string.suffix(from: string.index(after: idx))) + } + + // FIXME: We should investigate if it would be more efficient to instead + // return a path component iterator that does all its work lazily, moving + // from one path separator to the next on-demand. + // + var components: [String] { + // FIXME: This isn't particularly efficient; needs optimization, and + // in fact, it might well be best to return a custom iterator so we + // don't have to allocate everything up-front. It would be backed by + // the path string and just return a slice at a time. + let components = string.components(separatedBy: "/").filter({ !$0.isEmpty }) + + if string.hasPrefix("/") { + return ["/"] + components + } else { + return components + } + } + + var parentDirectory: Self { + return self == .root ? self : Self(string: dirname) + } + + init(string: String) { + self.string = string + } + + init(normalizingAbsolutePath path: String) { + precondition(path.first == "/", "Failure normalizing \(path), absolute paths should start with '/'") + + // At this point we expect to have a path separator as first character. + assert(path.first == "/") + // Fast path. + if !mayNeedNormalization(absolute: path) { + self.init(string: path) + } + + // Split the character array into parts, folding components as we go. + // As we do so, we count the number of characters we'll end up with in + // the normalized string representation. + var parts: [String] = [] + var capacity = 0 + for part in path.split(separator: "/") { + switch part.count { + case 0: + // Ignore empty path components. + continue + case 1 where part.first == ".": + // Ignore `.` path components. + continue + case 2 where part.first == "." && part.last == ".": + // If there's a previous part, drop it; otherwise, do nothing. + if let prev = parts.last { + parts.removeLast() + capacity -= prev.count + } + default: + // Any other component gets appended. + parts.append(String(part)) + capacity += part.count + } + } + capacity += max(parts.count, 1) + + // Create an output buffer using the capacity we've calculated. + // FIXME: Determine the most efficient way to reassemble a string. + var result = "" + result.reserveCapacity(capacity) + + // Put the normalized parts back together again. + var iter = parts.makeIterator() + result.append("/") + if let first = iter.next() { + result.append(contentsOf: first) + while let next = iter.next() { + result.append("/") + result.append(contentsOf: next) + } + } + + // Sanity-check the result (including the capacity we reserved). + assert(!result.isEmpty, "unexpected empty string") + assert(result.count == capacity, "count: " + + "\(result.count), cap: \(capacity)") + + // Use the result as our stored string. + self.init(string: result) + } + + init(normalizingRelativePath path: String) { + precondition(path.first != "/") + + // FIXME: Here we should also keep track of whether anything actually has + // to be changed in the string, and if not, just return the existing one. + + // Split the character array into parts, folding components as we go. + // As we do so, we count the number of characters we'll end up with in + // the normalized string representation. + var parts: [String] = [] + var capacity = 0 + for part in path.split(separator: "/") { + switch part.count { + case 0: + // Ignore empty path components. + continue + case 1 where part.first == ".": + // Ignore `.` path components. + continue + case 2 where part.first == "." && part.last == ".": + // If at beginning, fall through to treat the `..` literally. + guard let prev = parts.last else { + fallthrough + } + // If previous component is anything other than `..`, drop it. + if !(prev.count == 2 && prev.first == "." && prev.last == ".") { + parts.removeLast() + capacity -= prev.count + continue + } + // Otherwise, fall through to treat the `..` literally. + fallthrough + default: + // Any other component gets appended. + parts.append(String(part)) + capacity += part.count + } + } + capacity += max(parts.count - 1, 0) + + // Create an output buffer using the capacity we've calculated. + // FIXME: Determine the most efficient way to reassemble a string. + var result = "" + result.reserveCapacity(capacity) + + // Put the normalized parts back together again. + var iter = parts.makeIterator() + if let first = iter.next() { + result.append(contentsOf: first) + while let next = iter.next() { + result.append("/") + result.append(contentsOf: next) + } + } + + // Sanity-check the result (including the capacity we reserved). + assert(result.count == capacity, "count: " + + "\(result.count), cap: \(capacity)") + + // If the result is empty, return `.`, otherwise we return it as a string. + self.init(string: result.isEmpty ? "." : result) + } + + init(validatingAbsolutePath path: String) throws { + switch path.first { + case "/": + self.init(normalizingAbsolutePath: path) + case "~": + throw PathValidationError.startsWithTilde(path) + default: + throw PathValidationError.invalidAbsolutePath(path) + } + } + + init(validatingRelativePath path: String) throws { + switch path.first { + case "/": + throw PathValidationError.invalidRelativePath(path) + default: + self.init(normalizingRelativePath: path) + } + } + + func suffix(withDot: Bool) -> String? { + // FIXME: This method seems too complicated; it should be simplified, + // if possible, and certainly optimized (using UTF8View). + // Find the last path separator, if any. + let sIdx = string.lastIndex(of: "/") + // Find the start of the basename. + let bIdx = (sIdx != nil) ? string.index(after: sIdx!) : string.startIndex + // Find the last `.` (if any), starting from the second character of + // the basename (a leading `.` does not make the whole path component + // a suffix). + let fIdx = string.index(bIdx, offsetBy: 1, limitedBy: string.endIndex) ?? string.startIndex + if let idx = string[fIdx...].lastIndex(of: ".") { + // Unless it's just a `.` at the end, we have found a suffix. + if string.distance(from: idx, to: string.endIndex) > 1 { + let fromIndex = withDot ? idx : string.index(idx, offsetBy: 1) + return String(string.suffix(from: fromIndex)) + } else { + return nil + } + } + // If we get this far, there is no suffix. + return nil + } + + func appending(component name: String) -> Self { + assert(!name.contains("/"), "\(name) is invalid path component") + + // Handle pseudo paths. + switch name { + case "", ".": + return self + case "..": + return self.parentDirectory + default: + break + } + + if self == Self.root { + return Self(string: "/" + name) + } else { + return Self(string: string + "/" + name) + } + } + + func appending(relativePath: Self) -> Self { + // Both paths are already normalized. The only case in which we have + // to renormalize their concatenation is if the relative path starts + // with a `..` path component. + var newPathString = string + if self != .root { + newPathString.append("/") + } + + let relativePathString = relativePath.string + newPathString.append(relativePathString) + + // If the relative string starts with `.` or `..`, we need to normalize + // the resulting string. + // FIXME: We can actually optimize that case, since we know that the + // normalization of a relative path can leave `..` path components at + // the beginning of the path only. + if relativePathString.hasPrefix(".") { + if newPathString.hasPrefix("/") { + return Self(normalizingAbsolutePath: newPathString) + } else { + return Self(normalizingRelativePath: newPathString) + } + } else { + return Self(string: newPathString) + } + } +} +#endif + +/// Describes the way in which a path is invalid. +public enum PathValidationError: Error { + case startsWithTilde(String) + case invalidAbsolutePath(String) + case invalidRelativePath(String) +} + +extension PathValidationError: CustomStringConvertible { + public var description: String { + switch self { + case .startsWithTilde(let path): + return "invalid absolute path '\(path)'; absolute path must begin with '/'" + case .invalidAbsolutePath(let path): + return "invalid absolute path '\(path)'" + case .invalidRelativePath(let path): + return "invalid relative path '\(path)'; relative path should not begin with '\(AbsolutePath.root.pathString)'" + } + } +} + +extension AbsolutePath { + /// Returns a relative path that, when concatenated to `base`, yields the + /// callee path itself. If `base` is not an ancestor of the callee, the + /// returned path will begin with one or more `..` path components. + /// + /// Because both paths are absolute, they always have a common ancestor + /// (the root path, if nothing else). Therefore, any path can be made + /// relative to any other path by using a sufficient number of `..` path + /// components. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. Therefore, it does not take symbolic links into account. + public func relative(to base: AbsolutePath) -> RelativePath { + let result: RelativePath + // Split the two paths into their components. + // FIXME: The is needs to be optimized to avoid unncessary copying. + let pathComps = self.components + let baseComps = base.components + + // It's common for the base to be an ancestor, so try that first. + if pathComps.starts(with: baseComps) { + // Special case, which is a plain path without `..` components. It + // might be an empty path (when self and the base are equal). + let relComps = pathComps.dropFirst(baseComps.count) +#if os(Windows) + let pathString = relComps.joined(separator: "\\") +#else + let pathString = relComps.joined(separator: "/") +#endif + do { + result = try RelativePath(validating: pathString) + } catch { + preconditionFailure("invalid relative path computed from \(pathString)") + } + + } else { + // General case, in which we might well need `..` components to go + // "up" before we can go "down" the directory tree. + var newPathComps = ArraySlice(pathComps) + var newBaseComps = ArraySlice(baseComps) + while newPathComps.prefix(1) == newBaseComps.prefix(1) { + // First component matches, so drop it. + newPathComps = newPathComps.dropFirst() + newBaseComps = newBaseComps.dropFirst() + } + // Now construct a path consisting of as many `..`s as are in the + // `newBaseComps` followed by what remains in `newPathComps`. + var relComps = Array(repeating: "..", count: newBaseComps.count) + relComps.append(contentsOf: newPathComps) +#if os(Windows) + let pathString = relComps.joined(separator: "\\") +#else + let pathString = relComps.joined(separator: "/") +#endif + do { + result = try RelativePath(validating: pathString) + } catch { + preconditionFailure("invalid relative path computed from \(pathString)") + } + } + + assert(AbsolutePath(base, result) == self) + return result + } + + /// Returns true if the path contains the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + @available(*, deprecated, renamed: "isDescendantOfOrEqual(to:)") + public func contains(_ other: AbsolutePath) -> Bool { + return isDescendantOfOrEqual(to: other) + } + + /// Returns true if the path is an ancestor of the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isAncestor(of descendant: AbsolutePath) -> Bool { + return descendant.components.dropLast().starts(with: self.components) + } + + /// Returns true if the path is an ancestor of or equal to the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isAncestorOfOrEqual(to descendant: AbsolutePath) -> Bool { + return descendant.components.starts(with: self.components) + } + + /// Returns true if the path is a descendant of the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isDescendant(of ancestor: AbsolutePath) -> Bool { + return self.components.dropLast().starts(with: ancestor.components) + } + + /// Returns true if the path is a descendant of or equal to the given path. + /// + /// This method is strictly syntactic and does not access the file system + /// in any way. + public func isDescendantOfOrEqual(to ancestor: AbsolutePath) -> Bool { + return self.components.starts(with: ancestor.components) + } +} + +extension PathValidationError: CustomNSError { + public var errorUserInfo: [String : Any] { + return [NSLocalizedDescriptionKey: self.description] + } +} + +// FIXME: We should consider whether to merge the two `normalize()` functions. +// The argument for doing so is that some of the code is repeated; the argument +// against doing so is that some of the details are different, and since any +// given path is either absolute or relative, it's wasteful to keep checking +// for whether it's relative or absolute. Possibly we can do both by clever +// use of generics that abstract away the differences. + +/// Fast check for if a string might need normalization. +/// +/// This assumes that paths containing dotfiles are rare: +private func mayNeedNormalization(absolute string: String) -> Bool { + var last = UInt8(ascii: "0") + for c in string.utf8 { + switch c { + case UInt8(ascii: "/") where last == UInt8(ascii: "/"): + return true + case UInt8(ascii: ".") where last == UInt8(ascii: "/"): + return true + default: + break + } + last = c + } + if last == UInt8(ascii: "/") { + return true + } + return false +} + +// MARK: - `AbsolutePath` backwards compatibility, delete after deprecation period. + +extension AbsolutePath { + @_disfavoredOverload + @available(*, deprecated, message: "use throwing `init(validating:)` variant instead") + public init(_ absStr: String) { + try! self.init(validating: absStr) + } + + @_disfavoredOverload + @available(*, deprecated, message: "use throwing `init(validating:relativeTo:)` variant instead") + public init(_ str: String, relativeTo basePath: AbsolutePath) { + try! self.init(validating: str, relativeTo: basePath) + } + + @_disfavoredOverload + @available(*, deprecated, message: "use throwing variant instead") + public init(_ absPath: AbsolutePath, _ relStr: String) { + try! self.init(absPath, validating: relStr) + } +} + +// MARK: - `AbsolutePath` backwards compatibility, delete after deprecation period. + +extension RelativePath { + @_disfavoredOverload + @available(*, deprecated, message: "use throwing variant instead") + public init(_ string: String) { + try! self.init(validating: string) + } +} diff --git a/Tool/Sources/FileSystem/PathShim.swift b/Tool/Sources/FileSystem/PathShim.swift new file mode 100644 index 0000000..aacf2fc --- /dev/null +++ b/Tool/Sources/FileSystem/PathShim.swift @@ -0,0 +1,229 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors + ------------------------------------------------------------------------- + + This file contains temporary shim functions for use during the adoption of + AbsolutePath and RelativePath. The eventual plan is to use the FileSystem + API for all of this, at which time this file will go way. But since it is + important to have a quality FileSystem API, we will evolve it slowly. + + Meanwhile this file bridges the gap to let call sites be as clean as possible, + while making it fairly easy to find those calls later. + */ + +import Foundation + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// Returns the "real path" corresponding to `path` by resolving any symbolic links. +public func resolveSymlinks(_ path: AbsolutePath) throws -> AbsolutePath { + #if os(Windows) + let handle: HANDLE = path.pathString.withCString(encodedAs: UTF16.self) { + CreateFileW( + $0, + GENERIC_READ, + DWORD(FILE_SHARE_READ), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), + nil + ) + } + if handle == INVALID_HANDLE_VALUE { return path } + defer { CloseHandle(handle) } + return try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: 261) { + let dwLength: DWORD = + GetFinalPathNameByHandleW( + handle, + $0.baseAddress!, + DWORD($0.count), + DWORD(FILE_NAME_NORMALIZED) + ) + let path = String(decodingCString: $0.baseAddress!, as: UTF16.self) + return try AbsolutePath(path) + } + #else + let pathStr = path.pathString + + // FIXME: We can't use FileManager's destinationOfSymbolicLink because + // that implements readlink and not realpath. + if let resultPtr = realpath(pathStr, nil) { + let result = String(cString: resultPtr) + // If `resolved_path` is specified as NULL, then `realpath` uses + // malloc(3) to allocate a buffer [...]. The caller should deallocate + // this buffer using free(3). + // + // String.init(cString:) creates a new string by copying the + // null-terminated UTF-8 data referenced by the given pointer. + resultPtr.deallocate() + // FIXME: We should measure if it's really more efficient to compare the strings first. + return result == pathStr ? path : try AbsolutePath(validating: result) + } + + return path + #endif +} + +/// Creates a new, empty directory at `path`. If needed, any non-existent ancestor paths are also +/// created. If there is +/// already a directory at `path`, this function does nothing (in particular, this is not considered +/// to be an error). +public func makeDirectories(_ path: AbsolutePath) throws { + try FileManager.default.createDirectory( + atPath: path.pathString, + withIntermediateDirectories: true, + attributes: [:] + ) +} + +/// Creates a symbolic link at `path` whose content points to `dest`. If `relative` is true, the +/// symlink contents will +/// be a relative path, otherwise it will be absolute. +@available(*, deprecated, renamed: "localFileSystem.createSymbolicLink") +public func createSymlink( + _ path: AbsolutePath, + pointingAt dest: AbsolutePath, + relative: Bool = true +) throws { + let destString = relative ? dest.relative(to: path.parentDirectory).pathString : dest.pathString + try FileManager.default.createSymbolicLink( + atPath: path.pathString, + withDestinationPath: destString + ) +} + +/** + - Returns: a generator that walks the specified directory producing all + files therein. If recursively is true will enter any directories + encountered recursively. + + - Warning: directories that cannot be entered due to permission problems + are silently ignored. So keep that in mind. + + - Warning: Symbolic links that point to directories are *not* followed. + + - Note: setting recursively to `false` still causes the generator to feed + you the directory; just not its contents. + */ +public func walk( + _ path: AbsolutePath, + fileSystem: FileSystem = localFileSystem, + recursively: Bool = true +) throws -> RecursibleDirectoryContentsGenerator { + return try RecursibleDirectoryContentsGenerator( + path: path, + fileSystem: fileSystem, + recursionFilter: { _ in recursively } + ) +} + +/** + - Returns: a generator that walks the specified directory producing all + files therein. Directories are recursed based on the return value of + `recursing`. + + - Warning: directories that cannot be entered due to permissions problems + are silently ignored. So keep that in mind. + + - Warning: Symbolic links that point to directories are *not* followed. + + - Note: returning `false` from `recursing` still produces that directory + from the generator; just not its contents. + */ +public func walk( + _ path: AbsolutePath, + fileSystem: FileSystem = localFileSystem, + recursing: @escaping (AbsolutePath) -> Bool +) throws -> RecursibleDirectoryContentsGenerator { + return try RecursibleDirectoryContentsGenerator( + path: path, + fileSystem: fileSystem, + recursionFilter: recursing + ) +} + +/** + Produced by `walk`. + */ +public class RecursibleDirectoryContentsGenerator: IteratorProtocol, Sequence { + private var current: (path: AbsolutePath, iterator: IndexingIterator<[String]>) + private var towalk = [AbsolutePath]() + + private let shouldRecurse: (AbsolutePath) -> Bool + private let fileSystem: FileSystem + + fileprivate init( + path: AbsolutePath, + fileSystem: FileSystem, + recursionFilter: @escaping (AbsolutePath) -> Bool + ) throws { + self.fileSystem = fileSystem + // FIXME: getDirectoryContents should have an iterator version. + current = try ( + path, + fileSystem.getDirectoryContents(at: path).map(\.basename).makeIterator() + ) + shouldRecurse = recursionFilter + } + + public func next() -> AbsolutePath? { + outer: while true { + guard let entry = current.iterator.next() else { + while !towalk.isEmpty { + // FIXME: This looks inefficient. + let path = towalk.removeFirst() + guard shouldRecurse(path) else { continue } + // Ignore if we can't get content for this path. + guard let current = try? fileSystem.getDirectoryContents(at: path) + .map(\.basename) + .makeIterator() else { continue } + self.current = (path, current) + continue outer + } + return nil + } + + let path = current.path.appending(component: entry) + if fileSystem.isDirectory(path) && !fileSystem.isSymlink(path) { + towalk.append(path) + } + return path + } + } +} + +public extension AbsolutePath { + /// Returns a path suitable for display to the user (if possible, it is made + /// to be relative to the current working directory). + func prettyPath(cwd: AbsolutePath? = localFileSystem.currentWorkingDirectory) -> String { + guard let dir = cwd else { + // No current directory, display as is. + return pathString + } + // FIXME: Instead of string prefix comparison we should add a proper API + // to AbsolutePath to determine ancestry. + if self == dir { + return "." + } else if pathString.hasPrefix(dir.pathString + "/") { + return "./" + relative(to: dir).pathString + } else { + return pathString + } + } +} + diff --git a/Tool/Sources/FileSystem/WritableByteStream.swift b/Tool/Sources/FileSystem/WritableByteStream.swift new file mode 100644 index 0000000..94dd033 --- /dev/null +++ b/Tool/Sources/FileSystem/WritableByteStream.swift @@ -0,0 +1,846 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +/// Closable entity is one that manages underlying resources and needs to be closed for cleanup +/// The intent of this method is for the sole owner of the refernece/handle of the resource to close it completely, comapred to releasing a shared resource. +public protocol Closable { + func close() throws +} + +import Dispatch + +#if canImport(Glibc) +@_exported import Glibc +#elseif canImport(Musl) +@_exported import Musl +#elseif os(Windows) +@_exported import CRT +@_exported import WinSDK +#else +@_exported import Darwin.C +#endif + +/// Convert an integer in 0..<16 to its hexadecimal ASCII character. +private func hexdigit(_ value: UInt8) -> UInt8 { + return value < 10 ? (0x30 + value) : (0x41 + value - 10) +} + +/// Describes a type which can be written to a byte stream. +public protocol ByteStreamable { + func write(to stream: WritableByteStream) +} + +/// An output byte stream. +/// +/// This protocol is designed to be able to support efficient streaming to +/// different output destinations, e.g., a file or an in memory buffer. This is +/// loosely modeled on LLVM's llvm::raw_ostream class. +/// +/// The stream is generally used in conjunction with the `appending` function. +/// For example: +/// +/// let stream = BufferedOutputByteStream() +/// stream.appending("Hello, world!") +/// +/// would write the UTF8 encoding of "Hello, world!" to the stream. +/// +/// The stream accepts a number of custom formatting operators which are defined +/// in the `Format` struct (used for namespacing purposes). For example: +/// +/// let items = ["hello", "world"] +/// stream.appending(Format.asSeparatedList(items, separator: " ")) +/// +/// would write each item in the list to the stream, separating them with a +/// space. +public protocol WritableByteStream: AnyObject, TextOutputStream, Closable { + /// The current offset within the output stream. + var position: Int { get } + + /// Write an individual byte to the buffer. + func write(_ byte: UInt8) + + /// Write a collection of bytes to the buffer. + func write(_ bytes: C) where C.Element == UInt8 + + /// Flush the stream's buffer. + func flush() +} + +// Default noop implementation of close to avoid source-breaking downstream dependents with the addition of the close +// API. +public extension WritableByteStream { + func close() throws { } +} + +// Public alias to the old name to not introduce API compatibility. +public typealias OutputByteStream = WritableByteStream + +#if os(Android) || canImport(Musl) +public typealias FILEPointer = OpaquePointer +#else +public typealias FILEPointer = UnsafeMutablePointer +#endif + +extension WritableByteStream { + /// Write a sequence of bytes to the buffer. + public func write(sequence: S) where S.Iterator.Element == UInt8 { + // Iterate the sequence and append byte by byte since sequence's append + // is not performant anyway. + for byte in sequence { + write(byte) + } + } + + /// Write a string to the buffer (as UTF8). + public func write(_ string: String) { + // FIXME(performance): Use `string.utf8._copyContents(initializing:)`. + write(string.utf8) + } + + /// Write a string (as UTF8) to the buffer, with escaping appropriate for + /// embedding within a JSON document. + /// + /// - Note: This writes the literal data applying JSON string escaping, but + /// does not write any other characters (like the quotes that would surround + /// a JSON string). + public func writeJSONEscaped(_ string: String) { + // See RFC7159 for reference: https://tools.ietf.org/html/rfc7159 + for character in string.utf8 { + // Handle string escapes; we use constants here to directly match the RFC. + switch character { + // Literal characters. + case 0x20...0x21, 0x23...0x5B, 0x5D...0xFF: + write(character) + + // Single-character escaped characters. + case 0x22: // '"' + write(0x5C) // '\' + write(0x22) // '"' + case 0x5C: // '\\' + write(0x5C) // '\' + write(0x5C) // '\' + case 0x08: // '\b' + write(0x5C) // '\' + write(0x62) // 'b' + case 0x0C: // '\f' + write(0x5C) // '\' + write(0x66) // 'b' + case 0x0A: // '\n' + write(0x5C) // '\' + write(0x6E) // 'n' + case 0x0D: // '\r' + write(0x5C) // '\' + write(0x72) // 'r' + case 0x09: // '\t' + write(0x5C) // '\' + write(0x74) // 't' + + // Multi-character escaped characters. + default: + write(0x5C) // '\' + write(0x75) // 'u' + write(hexdigit(0)) + write(hexdigit(0)) + write(hexdigit(character >> 4)) + write(hexdigit(character & 0xF)) + } + } + } + + // MARK: helpers that return `self` + + // FIXME: This override shouldn't be necesary but removing it causes a 30% performance regression. This problem is + // tracked by the following bug: https://bugs.swift.org/browse/SR-8535 + @discardableResult + public func send(_ value: ArraySlice) -> WritableByteStream { + value.write(to: self) + return self + } + + @discardableResult + public func send(_ value: ByteStreamable) -> WritableByteStream { + value.write(to: self) + return self + } + + @discardableResult + public func send(_ value: CustomStringConvertible) -> WritableByteStream { + value.description.write(to: self) + return self + } + + @discardableResult + public func send(_ value: ByteStreamable & CustomStringConvertible) -> WritableByteStream { + value.write(to: self) + return self + } +} + +/// The `WritableByteStream` base class. +/// +/// This class provides a base and efficient implementation of the `WritableByteStream` +/// protocol. It can not be used as is-as subclasses as several functions need to be +/// implemented in subclasses. +public class _WritableByteStreamBase: WritableByteStream { + /// If buffering is enabled + @usableFromInline let _buffered : Bool + + /// The data buffer. + /// - Note: Minimum Buffer size should be one. + @usableFromInline var _buffer: [UInt8] + + /// Default buffer size of the data buffer. + private static let bufferSize = 1024 + + /// Queue to protect mutating operation. + fileprivate let queue = DispatchQueue(label: "org.swift.swiftpm.basic.stream") + + init(buffered: Bool) { + self._buffered = buffered + self._buffer = [] + + // When not buffered we still reserve 1 byte, as it is used by the + // by the single byte write() variant. + self._buffer.reserveCapacity(buffered ? _WritableByteStreamBase.bufferSize : 1) + } + + // MARK: Data Access API + + /// The current offset within the output stream. + public var position: Int { + return _buffer.count + } + + /// Currently available buffer size. + @usableFromInline var _availableBufferSize: Int { + return _buffer.capacity - _buffer.count + } + + /// Clears the buffer maintaining current capacity. + @usableFromInline func _clearBuffer() { + _buffer.removeAll(keepingCapacity: true) + } + + // MARK: Data Output API + + public final func flush() { + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + flushImpl() + } + + @usableFromInline func flushImpl() { + // Do nothing. + } + + public final func close() throws { + try closeImpl() + } + + @usableFromInline func closeImpl() throws { + fatalError("Subclasses must implement this") + } + + @usableFromInline func writeImpl(_ bytes: C) where C.Iterator.Element == UInt8 { + fatalError("Subclasses must implement this") + } + + @usableFromInline func writeImpl(_ bytes: ArraySlice) { + fatalError("Subclasses must implement this") + } + + /// Write an individual byte to the buffer. + public final func write(_ byte: UInt8) { + guard _buffered else { + _buffer.append(byte) + writeImpl(ArraySlice(_buffer)) + flushImpl() + _clearBuffer() + return + } + + // If buffer is full, write and clear it. + if _availableBufferSize == 0 { + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + } + + // This will need to change change if we ever have unbuffered stream. + precondition(_availableBufferSize > 0) + _buffer.append(byte) + } + + /// Write a collection of bytes to the buffer. + @inlinable public final func write(_ bytes: C) where C.Element == UInt8 { + guard _buffered else { + if let b = bytes as? ArraySlice { + // Fast path for unbuffered ArraySlice + writeImpl(b) + } else if let b = bytes as? Array { + // Fast path for unbuffered Array + writeImpl(ArraySlice(b)) + } else { + // generic collection unfortunately must be temporarily buffered + writeImpl(bytes) + } + flushImpl() + return + } + + // This is based on LLVM's raw_ostream. + let availableBufferSize = self._availableBufferSize + let byteCount = Int(bytes.count) + + // If we have to insert more than the available space in buffer. + if byteCount > availableBufferSize { + // If buffer is empty, start writing and keep the last chunk in buffer. + if _buffer.isEmpty { + let bytesToWrite = byteCount - (byteCount % availableBufferSize) + let writeUptoIndex = bytes.index(bytes.startIndex, offsetBy: numericCast(bytesToWrite)) + writeImpl(bytes.prefix(upTo: writeUptoIndex)) + + // If remaining bytes is more than buffer size write everything. + let bytesRemaining = byteCount - bytesToWrite + if bytesRemaining > availableBufferSize { + writeImpl(bytes.suffix(from: writeUptoIndex)) + return + } + // Otherwise keep remaining in buffer. + _buffer += bytes.suffix(from: writeUptoIndex) + return + } + + let writeUptoIndex = bytes.index(bytes.startIndex, offsetBy: numericCast(availableBufferSize)) + // Append whatever we can accommodate. + _buffer += bytes.prefix(upTo: writeUptoIndex) + + writeImpl(ArraySlice(_buffer)) + _clearBuffer() + + // FIXME: We should start again with remaining chunk but this doesn't work. Write everything for now. + //write(collection: bytes.suffix(from: writeUptoIndex)) + writeImpl(bytes.suffix(from: writeUptoIndex)) + return + } + _buffer += bytes + } +} + +/// The thread-safe wrapper around output byte streams. +/// +/// This class wraps any `WritableByteStream` conforming type to provide a type-safe +/// access to its operations. If the provided stream inherits from `_WritableByteStreamBase`, +/// it will also ensure it is type-safe will all other `ThreadSafeOutputByteStream` instances +/// around the same stream. +public final class ThreadSafeOutputByteStream: WritableByteStream { + private static let defaultQueue = DispatchQueue(label: "org.swift.swiftpm.basic.thread-safe-output-byte-stream") + public let stream: WritableByteStream + private let queue: DispatchQueue + + public var position: Int { + return queue.sync { + stream.position + } + } + + public init(_ stream: WritableByteStream) { + self.stream = stream + self.queue = (stream as? _WritableByteStreamBase)?.queue ?? ThreadSafeOutputByteStream.defaultQueue + } + + public func write(_ byte: UInt8) { + queue.sync { + stream.write(byte) + } + } + + public func write(_ bytes: C) where C.Element == UInt8 { + queue.sync { + stream.write(bytes) + } + } + + public func flush() { + queue.sync { + stream.flush() + } + } + + public func write(sequence: S) where S.Iterator.Element == UInt8 { + queue.sync { + stream.write(sequence: sequence) + } + } + + public func writeJSONEscaped(_ string: String) { + queue.sync { + stream.writeJSONEscaped(string) + } + } + + public func close() throws { + try queue.sync { + try stream.close() + } + } +} + + +#if swift(<5.6) +extension ThreadSafeOutputByteStream: UnsafeSendable {} +#else +extension ThreadSafeOutputByteStream: @unchecked Sendable {} +#endif + +/// Define an output stream operator. We need it to be left associative, so we +/// use `<<<`. +infix operator <<< : StreamingPrecedence +precedencegroup StreamingPrecedence { + associativity: left +} + +// MARK: Output Operator Implementations + +// FIXME: This override shouldn't be necesary but removing it causes a 30% performance regression. This problem is +// tracked by the following bug: https://bugs.swift.org/browse/SR-8535 + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ArraySlice) -> WritableByteStream { + value.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ByteStreamable) -> WritableByteStream { + value.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: CustomStringConvertible) -> WritableByteStream { + value.description.write(to: stream) + return stream +} + +@available(*, deprecated, message: "use send(_:) function on WritableByteStream instead") +@discardableResult +public func <<< (stream: WritableByteStream, value: ByteStreamable & CustomStringConvertible) -> WritableByteStream { + value.write(to: stream) + return stream +} + +extension UInt8: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension Character: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(String(self)) + } +} + +extension String: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self.utf8) + } +} + +extension Substring: ByteStreamable { + public func write(to stream: WritableByteStream) { + stream.write(self.utf8) + } +} + +extension StaticString: ByteStreamable { + public func write(to stream: WritableByteStream) { + withUTF8Buffer { stream.write($0) } + } +} + +extension Array: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension ArraySlice: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +extension ContiguousArray: ByteStreamable where Element == UInt8 { + public func write(to stream: WritableByteStream) { + stream.write(self) + } +} + +// MARK: Formatted Streaming Output + +/// Provides operations for returning derived streamable objects to implement various forms of formatted output. +public struct Format { + /// Write the input boolean encoded as a JSON object. + static public func asJSON(_ value: Bool) -> ByteStreamable { + return JSONEscapedBoolStreamable(value: value) + } + private struct JSONEscapedBoolStreamable: ByteStreamable { + let value: Bool + + func write(to stream: WritableByteStream) { + stream.send(value ? "true" : "false") + } + } + + /// Write the input integer encoded as a JSON object. + static public func asJSON(_ value: Int) -> ByteStreamable { + return JSONEscapedIntStreamable(value: value) + } + private struct JSONEscapedIntStreamable: ByteStreamable { + let value: Int + + func write(to stream: WritableByteStream) { + // FIXME: Diagnose integers which cannot be represented in JSON. + stream.send(value.description) + } + } + + /// Write the input double encoded as a JSON object. + static public func asJSON(_ value: Double) -> ByteStreamable { + return JSONEscapedDoubleStreamable(value: value) + } + private struct JSONEscapedDoubleStreamable: ByteStreamable { + let value: Double + + func write(to stream: WritableByteStream) { + // FIXME: What should we do about NaN, etc.? + // + // FIXME: Is Double.debugDescription the best representation? + stream.send(value.debugDescription) + } + } + + /// Write the input CustomStringConvertible encoded as a JSON object. + static public func asJSON(_ value: T) -> ByteStreamable { + return JSONEscapedStringStreamable(value: value.description) + } + /// Write the input string encoded as a JSON object. + static public func asJSON(_ string: String) -> ByteStreamable { + return JSONEscapedStringStreamable(value: string) + } + private struct JSONEscapedStringStreamable: ByteStreamable { + let value: String + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "\"")) + stream.writeJSONEscaped(value) + stream.send(UInt8(ascii: "\"")) + } + } + + /// Write the input string list encoded as a JSON object. + static public func asJSON(_ items: [T]) -> ByteStreamable { + return JSONEscapedStringListStreamable(items: items.map({ $0.description })) + } + /// Write the input string list encoded as a JSON object. + // + // FIXME: We might be able to make this more generic through the use of a "JSONEncodable" protocol. + static public func asJSON(_ items: [String]) -> ByteStreamable { + return JSONEscapedStringListStreamable(items: items) + } + private struct JSONEscapedStringListStreamable: ByteStreamable { + let items: [String] + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "[")) + for (i, item) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(item)) + } + stream.send(UInt8(ascii: "]")) + } + } + + /// Write the input dictionary encoded as a JSON object. + static public func asJSON(_ items: [String: String]) -> ByteStreamable { + return JSONEscapedDictionaryStreamable(items: items) + } + private struct JSONEscapedDictionaryStreamable: ByteStreamable { + let items: [String: String] + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "{")) + for (offset: i, element: (key: key, value: value)) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(key)).send(":").send(Format.asJSON(value)) + } + stream.send(UInt8(ascii: "}")) + } + } + + /// Write the input list (after applying a transform to each item) encoded as a JSON object. + // + // FIXME: We might be able to make this more generic through the use of a "JSONEncodable" protocol. + static public func asJSON(_ items: [T], transform: @escaping (T) -> String) -> ByteStreamable { + return JSONEscapedTransformedStringListStreamable(items: items, transform: transform) + } + private struct JSONEscapedTransformedStringListStreamable: ByteStreamable { + let items: [T] + let transform: (T) -> String + + func write(to stream: WritableByteStream) { + stream.send(UInt8(ascii: "[")) + for (i, item) in items.enumerated() { + if i != 0 { stream.send(",") } + stream.send(Format.asJSON(transform(item))) + } + stream.send(UInt8(ascii: "]")) + } + } + + /// Write the input list to the stream with the given separator between items. + static public func asSeparatedList(_ items: [T], separator: String) -> ByteStreamable { + return SeparatedListStreamable(items: items, separator: separator) + } + private struct SeparatedListStreamable: ByteStreamable { + let items: [T] + let separator: String + + func write(to stream: WritableByteStream) { + for (i, item) in items.enumerated() { + // Add the separator, if necessary. + if i != 0 { + stream.send(separator) + } + + stream.send(item) + } + } + } + + /// Write the input list to the stream (after applying a transform to each item) with the given separator between + /// items. + static public func asSeparatedList( + _ items: [T], + transform: @escaping (T) -> ByteStreamable, + separator: String + ) -> ByteStreamable { + return TransformedSeparatedListStreamable(items: items, transform: transform, separator: separator) + } + private struct TransformedSeparatedListStreamable: ByteStreamable { + let items: [T] + let transform: (T) -> ByteStreamable + let separator: String + + func write(to stream: WritableByteStream) { + for (i, item) in items.enumerated() { + if i != 0 { stream.send(separator) } + stream.send(transform(item)) + } + } + } + + static public func asRepeating(string: String, count: Int) -> ByteStreamable { + return RepeatingStringStreamable(string: string, count: count) + } + private struct RepeatingStringStreamable: ByteStreamable { + let string: String + let count: Int + + init(string: String, count: Int) { + precondition(count >= 0, "Count should be >= zero") + self.string = string + self.count = count + } + + func write(to stream: WritableByteStream) { + for _ in 0..(_ bytes: C) where C.Iterator.Element == UInt8 { + contents += bytes + } + override final func writeImpl(_ bytes: ArraySlice) { + contents += bytes + } + + override final func closeImpl() throws { + // Do nothing. The protocol does not require to stop receiving writes, close only signals that resources could + // be released at this point should we need to. + } +} + +/// Represents a stream which is backed to a file. Not for instantiating. +public class FileOutputByteStream: _WritableByteStreamBase { + + public override final func closeImpl() throws { + flush() + try fileCloseImpl() + } + + /// Closes the file flushing any buffered data. + func fileCloseImpl() throws { + fatalError("fileCloseImpl() should be implemented by a subclass") + } +} + +/// Implements file output stream for local file system. +public final class LocalFileOutputByteStream: FileOutputByteStream { + + /// The pointer to the file. + let filePointer: FILEPointer + + /// Set to an error value if there were any IO error during writing. + private var error: FileSystemError? + + /// Closes the file on deinit if true. + private var closeOnDeinit: Bool + + /// Path to the file this stream should operate on. + private let path: AbsolutePath? + + /// Instantiate using the file pointer. + public init(filePointer: FILEPointer, closeOnDeinit: Bool = true, buffered: Bool = true) throws { + self.filePointer = filePointer + self.closeOnDeinit = closeOnDeinit + self.path = nil + super.init(buffered: buffered) + } + + /// Opens the file for writing at the provided path. + /// + /// - Parameters: + /// - path: Path to the file this stream should operate on. + /// - closeOnDeinit: If true closes the file on deinit. clients can use + /// close() if they want to close themselves or catch + /// errors encountered during writing to the file. + /// Default value is true. + /// - buffered: If true buffers writes in memory until full or flush(). + /// Otherwise, writes are processed and flushed immediately. + /// Default value is true. + /// + /// - Throws: FileSystemError + public init(_ path: AbsolutePath, closeOnDeinit: Bool = true, buffered: Bool = true) throws { + guard let filePointer = fopen(path.pathString, "wb") else { + throw FileSystemError(errno: errno, path) + } + self.path = path + self.filePointer = filePointer + self.closeOnDeinit = closeOnDeinit + super.init(buffered: buffered) + } + + deinit { + if closeOnDeinit { + fclose(filePointer) + } + } + + func errorDetected(code: Int32?) { + if let code = code { + error = .init(.ioError(code: code), path) + } else { + error = .init(.unknownOSError, path) + } + } + + override final func writeImpl(_ bytes: C) where C.Iterator.Element == UInt8 { + // FIXME: This will be copying bytes but we don't have option currently. + var contents = [UInt8](bytes) + while true { + let n = fwrite(&contents, 1, contents.count, filePointer) + if n < 0 { + if errno == EINTR { continue } + errorDetected(code: errno) + } else if n != contents.count { + errorDetected(code: nil) + } + break + } + } + + override final func writeImpl(_ bytes: ArraySlice) { + bytes.withUnsafeBytes { bytesPtr in + while true { + let n = fwrite(bytesPtr.baseAddress!, 1, bytesPtr.count, filePointer) + if n < 0 { + if errno == EINTR { continue } + errorDetected(code: errno) + } else if n != bytesPtr.count { + errorDetected(code: nil) + } + break + } + } + } + + override final func flushImpl() { + fflush(filePointer) + } + + override final func fileCloseImpl() throws { + defer { + fclose(filePointer) + // If clients called close we shouldn't call fclose again in deinit. + closeOnDeinit = false + } + // Throw if errors were found during writing. + if let error = error { + throw error + } + } +} + +/// Public stdout stream instance. +public var stdoutStream: ThreadSafeOutputByteStream = try! ThreadSafeOutputByteStream(LocalFileOutputByteStream( + filePointer: stdout, + closeOnDeinit: false)) + +/// Public stderr stream instance. +public var stderrStream: ThreadSafeOutputByteStream = try! ThreadSafeOutputByteStream(LocalFileOutputByteStream( + filePointer: stderr, + closeOnDeinit: false)) diff --git a/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift new file mode 100644 index 0000000..f88d3a7 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Conversation/ConversationProgressHandler.swift @@ -0,0 +1,54 @@ +import Combine +import Foundation +import JSONRPC +import LanguageServerProtocol + +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 } + 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>() + + 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 { + print("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)) + } + } + } + + private func getValueAsString(from token: ProgressToken) -> String? { + switch token { + case .optionA(let intValue): + return String(intValue) + case .optionB(let stringValue): + return stringValue + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift new file mode 100644 index 0000000..b99cf7d --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -0,0 +1,158 @@ +import BuiltinExtension +import CopilotForXcodeKit +import ConversationServiceProvider +import Foundation +import LanguageServerProtocol +import Logger +import Preferences +import Workspace + +public final class GitHubCopilotExtension: BuiltinExtension { + public var suggestionServiceId: Preferences.BuiltInSuggestionFeatureProvider { .gitHubCopilot } + + public let suggestionService: GitHubCopilotSuggestionService? + + public let conversationService: ConversationServiceType? + + private var extensionUsage = ExtensionUsage( + isSuggestionServiceInUse: false, + isChatServiceInUse: false + ) + private var isLanguageServerInUse: Bool { + extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse + } + + let workspacePool: WorkspacePool + + let serviceLocator: ServiceLocator + + public init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + serviceLocator = .init(workspacePool: workspacePool) + let suggestionService = GitHubCopilotSuggestionService.init(serviceLocator: serviceLocator) + self.suggestionService = suggestionService + let conversationService = GitHubCopilotConversationService.init(serviceLocator: serviceLocator) + self.conversationService = conversationService + } + + public func workspaceDidOpen(_: WorkspaceInfo) {} + + public func workspaceDidClose(_: WorkspaceInfo) {} + + public func workspace(_ workspace: WorkspaceInfo, didOpenDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + 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) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didSaveDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifySaveTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace(_ workspace: WorkspaceInfo, didCloseDocumentAt documentURL: URL) { + guard isLanguageServerInUse else { return } + Task { + do { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.notifyCloseTextDocument(fileURL: documentURL) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String? + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + 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 { + Logger.gitHubCopilot.error(error.localizedDescription) + } + } + } + + public func extensionUsageDidChange(_ usage: ExtensionUsage) { + extensionUsage = usage + if !usage.isChatServiceInUse && !usage.isSuggestionServiceInUse { + terminate() + } + } + + public func terminate() { + for workspace in workspacePool.workspaces.values { + guard let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { continue } + plugin.terminate() + } + } +} + +protocol ServiceLocatorType { + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? +} + +final class ServiceLocator: ServiceLocatorType { + let workspacePool: WorkspacePool + + init(workspacePool: WorkspacePool) { + self.workspacePool = workspacePool + } + + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? { + guard let workspace = workspacePool.workspaces[workspace.workspaceURL], + let plugin = workspace.plugin(for: GitHubCopilotWorkspacePlugin.self) + else { + return nil + } + return plugin.gitHubCopilotService + } +} diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift new file mode 100644 index 0000000..1b6edf9 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotWorkspacePlugin.swift @@ -0,0 +1,50 @@ +import Foundation +import Logger +import Workspace + +public final class GitHubCopilotWorkspacePlugin: WorkspacePlugin { + public var gitHubCopilotService: GitHubCopilotService? + + public override init(workspace: Workspace) { + super.init(workspace: workspace) + do { + gitHubCopilotService = try createGitHubCopilotService() + } catch { + Logger.gitHubCopilot.error("Failed to create GitHub Copilot service: \(error)") + } + } + + deinit { + if let gitHubCopilotService { + Task { await gitHubCopilotService.terminate() } + } + } + + func createGitHubCopilotService() throws -> GitHubCopilotService { + let newService = try GitHubCopilotService(projectRootURL: projectRootURL) + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + finishLaunchingService() + } + return newService + } + + func finishLaunchingService() { + guard let workspace, let gitHubCopilotService else { return } + Task { + for (_, filespace) in workspace.filespaces { + let documentURL = filespace.fileURL + guard let content = try? String(contentsOf: documentURL) else { continue } + try? await gitHubCopilotService.notifyOpenTextDocument( + fileURL: documentURL, + content: content + ) + } + } + } + + func terminate() { + gitHubCopilotService = nil + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift new file mode 100644 index 0000000..18273d0 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift @@ -0,0 +1,324 @@ +import Combine +import Foundation +import JSONRPC +import LanguageClient +import LanguageServerProtocol +import Logger +import ProcessEnv + +/// 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 cancellables = Set() + var terminationHandler: (() -> Void)? + @MainActor var ongoingCompletionRequestIDs: [JSONId] = [] + @MainActor var ongoingConversationRequestIDs = [String: JSONId]() + + public convenience init( + path: String, + arguments: [String], + environment: [String: String]? = nil + ) { + let params = Process.ExecutionParameters( + path: path, + arguments: arguments, + environment: environment + ) + + self.init(executionParameters: params) + } + + init(executionParameters parameters: Process.ExecutionParameters) { + transport = StdioDataTransport() + let framing = SeperatedHTTPHeaderMessageFraming() + let messageTransport = MessageTransport( + dataTransport: transport, + messageProtocol: framing + ) + customTransport = CustomDataTransport(nextTransport: messageTransport) + wrappedServer = CustomJSONRPCLanguageServer(dataTransport: customTransport) + + process = Process() + + // Because the implementation of LanguageClient is so closed, + // we need to get the request IDs from a custom transport before the data + // is written to the language server. + customTransport.onWriteRequest = { [weak self] request in + if request.method == "getCompletionsCycling" { + Task { @MainActor [weak self] in + self?.ongoingCompletionRequestIDs.append(request.id) + } + } else if request.method == "conversation/create" { + Task { @MainActor [weak self] in + if let paramsData = try? JSONEncoder().encode(request.params) { + do { + let params = try JSONDecoder().decode(ConversationCreateParams.self, from: paramsData) + self?.ongoingConversationRequestIDs[params.workDoneToken] = request.id + } catch { + // Handle decoding error + print("Error decoding ConversationCreateParams: \(error)") + } + } + } + } 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)") + } + } + } + } + } + + 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() + + // releasing the server here will short-circuit any pending requests, + // which might otherwise take a while to time out, if ever. + wrappedServer = nil + terminationHandler?() + } + + var logMessages: Bool { + get { return wrappedServer?.logMessages ?? false } + set { wrappedServer?.logMessages = newValue } + } +} + +extension CopilotLocalProcessServer: LanguageServerProtocol.Server { + public var requestHandler: RequestHandler? { + get { return wrappedServer?.requestHandler } + set { wrappedServer?.requestHandler = newValue } + } + + public var notificationHandler: NotificationHandler? { + get { wrappedServer?.notificationHandler } + set { wrappedServer?.notificationHandler = newValue } + } + + public func sendNotification( + _ notif: ClientNotification, + completionHandler: @escaping (ServerError?) -> Void + ) { + guard let server = wrappedServer, process.isRunning else { + completionHandler(.serverUnavailable) + return + } + + server.sendNotification(notif, completionHandler: completionHandler) + } + + /// Cancel ongoing completion requests. + public func cancelOngoingTasks() async { + let task = Task { @MainActor in + for id in ongoingCompletionRequestIDs { + await cancelTask(id) + } + self.ongoingCompletionRequestIDs = [] + } + await task.value + } + + public func cancelOngoingTask(_ workDoneToken: String) async { + let task = Task { @MainActor in + guard let id = ongoingConversationRequestIDs[workDoneToken] else { return } + await cancelTask(id) + } + await task.value + } + + public func cancelTask(_ id: JSONId) async { + guard let server = wrappedServer, process.isRunning else { + return + } + + switch id { + case let .numericId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + case let .stringId(id): + try? await server.sendNotification(.protocolCancelRequest(.init(id: id))) + } + } + + public func sendRequest( + _ request: ClientRequest, + completionHandler: @escaping (ServerResult) -> Void + ) { + guard let server = wrappedServer, process.isRunning else { + completionHandler(.failure(.serverUnavailable)) + return + } + + 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 } + } +} + +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)") + block(nil) + return true + case "featureFlagsNotification": + notificationPublisher.send(anyNotification) + block(nil) + return true + case "conversation/preconditionsNotification": + // Ignore + block(nil) + return true + default: + return false + } + } + } + + public func sendNotification( + _ notif: ClientNotification, + completionHandler: @escaping (ServerError?) -> Void + ) { + internalServer.sendNotification(notif, completionHandler: completionHandler) + } +} + +extension CustomJSONRPCLanguageServer { + private func handleRequest( + _ request: AnyJSONRPCRequest, + data: Data, + callback: @escaping (AnyJSONRPCResponse) -> Void + ) -> Bool { + return false + } +} + +extension CustomJSONRPCLanguageServer { + public func sendRequest( + _ request: ClientRequest, + completionHandler: @escaping (ServerResult) -> Void + ) { + internalServer.sendRequest(request, completionHandler: completionHandler) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift new file mode 100644 index 0000000..82e9854 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CustomStdioTransport.swift @@ -0,0 +1,30 @@ +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/GitHubCopilotAccountStatus.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift new file mode 100644 index 0000000..fab47ca --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotAccountStatus.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum GitHubCopilotAccountStatus: String, Codable, CustomStringConvertible { + case alreadySignedIn = "AlreadySignedIn" + case maybeOk = "MaybeOk" + case notAuthorized = "NotAuthorized" + case notSignedIn = "NotSignedIn" + case ok = "OK" + + public var description: String { + switch self { + case .alreadySignedIn: + return "Already Signed In" + case .maybeOk: + return "Maybe OK" + case .notAuthorized: + return "Not Authorized" + case .notSignedIn: + return "Not Signed In" + case .ok: + return "OK" + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift new file mode 100644 index 0000000..e918ac6 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..4263774 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift @@ -0,0 +1,374 @@ +import Foundation +import JSONRPC +import LanguageServerProtocol +import SuggestionBasic + +struct GitHubCopilotDoc: Codable { + var source: String + var tabSize: Int + var indentSize: Int + var insertSpaces: Bool + var path: String + var uri: String + var relativePath: String + var languageId: CodeLanguage + var position: Position + /// Buffer version. Not sure what this is for, not sure how to get it + var version: Int = 0 +} + +protocol GitHubCopilotRequestType { + associatedtype Response: Codable + var request: ClientRequest { get } +} + +public struct GitHubCopilotCodeSuggestion: Codable, Equatable { + public init( + text: String, + position: CursorPosition, + uuid: String, + range: CursorRange, + displayText: String + ) { + self.text = text + self.position = position + self.uuid = uuid + self.range = range + self.displayText = displayText + } + + /// The new code to be inserted and the original code on the first line. + public var text: String + /// The position of the cursor before generating the completion. + public var position: CursorPosition + /// An id. + public var uuid: String + /// The range of the original code that should be replaced. + public var range: CursorRange + /// The new code to be inserted. + public var displayText: String +} + +enum GitHubCopilotRequest { + // TODO migrate from setEditorInfo to didConfigurationChange + struct SetEditorInfo: GitHubCopilotRequestType { + struct Response: Codable {} + + let versionNumber = JSONValue(stringLiteral: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") + let xcodeVersion = JSONValue(stringLiteral: SystemInfo().xcodeVersion() ?? "") + + var networkProxy: JSONValue? { + let host = UserDefaults.shared.value(for: \.gitHubCopilotProxyHost) + if host.isEmpty { return nil } + var port = UserDefaults.shared.value(for: \.gitHubCopilotProxyPort) + if port.isEmpty { port = "80" } + let username = UserDefaults.shared.value(for: \.gitHubCopilotProxyUsername) + if username.isEmpty { + return .hash([ + "host": .string(host), + "port": .number(Double(Int(port) ?? 80)), + "rejectUnauthorized": .bool(UserDefaults.shared + .value(for: \.gitHubCopilotUseStrictSSL)), + ]) + } else { + return .hash([ + "host": .string(host), + "port": .number(Double(Int(port) ?? 80)), + "rejectUnauthorized": .bool(UserDefaults.shared + .value(for: \.gitHubCopilotUseStrictSSL)), + "username": .string(username), + "password": .string(UserDefaults.shared + .value(for: \.gitHubCopilotProxyPassword)), + + ]) + } + } + + var authProvider: JSONValue? { + var dict: [String: JSONValue] = [:] + let enterpriseURI = UserDefaults.shared.value(for: \.gitHubCopilotEnterpriseURI) + if !enterpriseURI.isEmpty { + dict["url"] = .string(enterpriseURI) + } + + if dict.isEmpty { return nil } + return .hash(dict) + } + + var request: ClientRequest { + var dict: [String: JSONValue] = [ + "editorInfo": .hash([ + "name": "Xcode", + "version": xcodeVersion, + ]), + "editorPluginInfo": .hash([ + "name": "copilot-xcode", + "version": versionNumber, + ]), + ] + + dict["authProvider"] = authProvider + dict["networkProxy"] = networkProxy + + return .custom("setEditorInfo", .hash(dict)) + } + + } + + struct GetVersion: GitHubCopilotRequestType { + struct Response: Codable { + var version: String + } + + var request: ClientRequest { + .custom("getVersion", .hash([:])) + } + } + + struct CheckStatus: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + } + + var request: ClientRequest { + .custom("checkStatus", .hash([:])) + } + } + + struct SignInInitiate: GitHubCopilotRequestType { + struct Response: Codable { + var verificationUri: String + var status: String + var userCode: String + var expiresIn: Int + var interval: Int + } + + var request: ClientRequest { + .custom("signInInitiate", .hash([:])) + } + } + + struct SignInConfirm: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + var user: String + } + + var userCode: String + + var request: ClientRequest { + .custom("signInConfirm", .hash([ + "userCode": .string(userCode), + ])) + } + } + + struct SignOut: GitHubCopilotRequestType { + struct Response: Codable { + var status: GitHubCopilotAccountStatus + } + + var request: ClientRequest { + .custom("signOut", .hash([:])) + } + } + + struct GetCompletions: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getCompletions", .hash([ + "doc": dict, + ])) + } + } + + struct GetCompletionsCycling: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getCompletionsCycling", .hash([ + "doc": dict, + ])) + } + } + + struct InlineCompletion: GitHubCopilotRequestType { + struct Response: Codable { + var items: [InlineCompletionItem] + } + + struct InlineCompletionItem: Codable { + var insertText: String + var filterText: String? + var range: Range? + var command: Command? + + struct Range: Codable { + var start: Position + var end: Position + } + + struct Command: Codable { + var title: String + var command: String + var arguments: [String]? + } + } + + var doc: Input + + struct Input: Codable { + var textDocument: _TextDocument; struct _TextDocument: Codable { + var uri: String + var version: Int + } + + var position: Position + var formattingOptions: FormattingOptions + var context: _Context; struct _Context: Codable { + enum TriggerKind: Int, Codable { + case invoked = 1 + case automatic = 2 + } + + var triggerKind: TriggerKind + } + } + + 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) + } + } + + struct GetPanelCompletions: GitHubCopilotRequestType { + struct Response: Codable { + var completions: [GitHubCopilotCodeSuggestion] + } + + var doc: GitHubCopilotDoc + + var request: ClientRequest { + let data = (try? JSONEncoder().encode(doc)) ?? Data() + let dict = (try? JSONDecoder().decode(JSONValue.self, from: data)) ?? .hash([:]) + return .custom("getPanelCompletions", .hash([ + "doc": dict, + ])) + } + } + + struct NotifyShown: GitHubCopilotRequestType { + struct Response: Codable {} + + var completionUUID: String + + var request: ClientRequest { + .custom("notifyShown", .hash([ + "uuid": .string(completionUUID), + ])) + } + } + + struct NotifyAccepted: GitHubCopilotRequestType { + struct Response: Codable {} + + var completionUUID: String + + var acceptedLength: Int? + + var request: ClientRequest { + var dict: [String: JSONValue] = [ + "uuid": .string(completionUUID), + ] + if let acceptedLength { + dict["acceptedLength"] = .number(Double(acceptedLength)) + } + + return .custom("notifyAccepted", .hash(dict)) + } + } + + struct NotifyRejected: GitHubCopilotRequestType { + struct Response: Codable {} + + var completionUUIDs: [String] + + var request: ClientRequest { + .custom("notifyRejected", .hash([ + "uuids": .array(completionUUIDs.map(JSONValue.string)), + ])) + } + } + + // MARK: Conversation + + struct CreateConversation: GitHubCopilotRequestType { + struct Response: Codable {} + + 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) + } + } + + // MARK: Conversation turn + + struct CreateTurn: GitHubCopilotRequestType { + struct Response: Codable {} + + 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) + } + } + + // MARK: Conversation rating + + struct ConversationRating: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: ConversationRatingParams + + 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) + } + } + + // MARK: Copy code + + struct CopyCode: GitHubCopilotRequestType { + struct Response: Codable {} + + var params: CopyCodeParams + + 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) + } + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift new file mode 100644 index 0000000..40a9f84 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -0,0 +1,608 @@ +import AppKit +import Combine +import ConversationServiceProvider +import Foundation +import JSONRPC +import LanguageClient +import LanguageServerProtocol +import Logger +import Preferences +import SuggestionBasic + +public protocol GitHubCopilotAuthServiceType { + func checkStatus() async throws -> GitHubCopilotAccountStatus + func signInInitiate() async throws -> (verificationUri: String, userCode: String) + func signInConfirm(userCode: String) async throws + -> (username: String, status: GitHubCopilotAccountStatus) + func signOut() async throws -> GitHubCopilotAccountStatus + func version() async throws -> String +} + +public protocol GitHubCopilotSuggestionServiceType { + func getCompletions( + fileURL: URL, + content: String, + originalContent: String, + cursorPosition: CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) async throws -> [CodeSuggestion] + func notifyShown(_ completion: CodeSuggestion) async + func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int?) async + func notifyRejected(_ completions: [CodeSuggestion]) async + func notifyOpenTextDocument(fileURL: URL, content: String) async throws + func notifyChangeTextDocument(fileURL: URL, content: String, version: Int) async throws + func notifyCloseTextDocument(fileURL: URL) async throws + func notifySaveTextDocument(fileURL: URL) async throws + func cancelRequest() async + func terminate() async +} + +public protocol GitHubCopilotConversationServiceType { + func createConversation(_ message: String, + workDoneToken: String, + workspaceFolder: String, + doc: Doc?, + skills: [String]) async throws + func createTurn(_ message: String, + workDoneToken: String, + conversationId: String, + doc: Doc?) 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 +} + +protocol GitHubCopilotLSP { + func sendRequest(_ endpoint: E) async throws -> E.Response + func sendNotification(_ notif: ClientNotification) async throws +} + +public enum GitHubCopilotError: Error, LocalizedError { + case languageServerNotInstalled + case languageServerError(ServerError) + case failedToInstallStartScript + + public var errorDescription: String? { + switch self { + case .languageServerNotInstalled: + return "Language server is not installed." + case .failedToInstallStartScript: + return "Failed to install start script." + case let .languageServerError(error): + switch error { + case let .handlerUnavailable(handler): + return "Language server error: Handler \(handler) unavailable" + case let .unhandledMethod(method): + return "Language server error: Unhandled method \(method)" + case let .notificationDispatchFailed(error): + return "Language server error: Notification dispatch failed: \(error)" + case let .requestDispatchFailed(error): + return "Language server error: Request dispatch failed: \(error)" + case let .clientDataUnavailable(error): + return "Language server error: Client data unavailable: \(error)" + case .serverUnavailable: + return "Language server error: Server unavailable, please make sure that:\n1. The path to node is correctly set.\n2. The node is not a shim executable.\n3. the node version is high enough." + case .missingExpectedParameter: + return "Language server error: Missing expected parameter" + case .missingExpectedResult: + return "Language server error: Missing expected result" + case let .unableToDecodeRequest(error): + return "Language server error: Unable to decode request: \(error)" + case let .unableToSendRequest(error): + return "Language server error: Unable to send request: \(error)" + case let .unableToSendNotification(error): + return "Language server error: Unable to send notification: \(error)" + case let .serverError(code: code, message: message, data: data): + return "Language server error: Server error: \(code) \(message) \(String(describing: data))" + case .invalidRequest: + return "Language server error: Invalid request" + case .timeout: + return "Language server error: Timeout, please try again later" + } + } + } +} + +public extension Notification.Name { + static let gitHubCopilotShouldRefreshEditorInformation = Notification + .Name("com.github.CopilotForXcode.GitHubCopilotShouldRefreshEditorInformation") +} + +public class GitHubCopilotBaseService { + let projectRootURL: URL + var server: GitHubCopilotLSP + var localProcessServer: CopilotLocalProcessServer? + + init(designatedServer: GitHubCopilotLSP) { + projectRootURL = URL(fileURLWithPath: "/") + server = designatedServer + } + + init(projectRootURL: URL) throws { + self.projectRootURL = projectRootURL + let (server, localServer) = try { + let urls = try GitHubCopilotBaseService.createFoldersIfNeeded() + var path = SystemInfo().binaryPath() + var args = ["--stdio"] + let home = ProcessInfo.processInfo.homePath + let versionNumber = JSONValue(stringLiteral: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") + let xcodeVersion = JSONValue(stringLiteral: SystemInfo().xcodeVersion() ?? "") + + #if DEBUG + // Use local language server if set and available + if let languageServerPath = Bundle.main.infoDictionary?["LANGUAGE_SERVER_PATH"] as? String { + let jsPath = URL(fileURLWithPath: NSString(string: languageServerPath).expandingTildeInPath) + .appendingPathComponent("dist") + .appendingPathComponent("language-server.js") + 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"] + 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"] + #else + let environment: [String: String] = ["HOME": home] + #endif + + let executionParams = Process.ExecutionParameters( + path: path, + arguments: args, + environment: environment, + currentDirectoryURL: urls.supportURL + ) + + let localServer = CopilotLocalProcessServer(executionParameters: executionParams) + localServer.notificationHandler = { _, respond in + respond(.timeout) + } + let server = InitializingServer(server: localServer) + server.initializeParamsProvider = { + let capabilities = ClientCapabilities( + workspace: nil, + textDocument: nil, + window: nil, + general: nil, + experimental: nil + ) + + return InitializeParams( + processId: Int(ProcessInfo.processInfo.processIdentifier), + clientInfo: .init( + name: "copilot-xcode", + version: "1.5.0.5206-nightly" + ), + locale: nil, + rootPath: projectRootURL.path, + rootUri: projectRootURL.path, + initializationOptions: [ + "editorInfo": [ + "name": "Xcode", + "version": xcodeVersion, + ], + "editorPluginInfo": [ + "name": "copilot-xcode", + "version": versionNumber, + ], + ], + capabilities: capabilities, + trace: .off, + workspaceFolders: [WorkspaceFolder( + uri: projectRootURL.path, + name: projectRootURL.lastPathComponent + )] + ) + } + + return (server, localServer) + }() + + self.server = server + localProcessServer = localServer + + let notifications = NotificationCenter.default + .notifications(named: .gitHubCopilotShouldRefreshEditorInformation) + Task { [weak self] in + _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + + for await _ in notifications { + guard self != nil else { return } + _ = try? await server.sendRequest(GitHubCopilotRequest.SetEditorInfo()) + } + } + } + + + public static func createFoldersIfNeeded() throws -> ( + applicationSupportURL: URL, + gitHubCopilotURL: URL, + executableURL: URL, + supportURL: URL + ) { + guard let supportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent( + Bundle.main + .object(forInfoDictionaryKey: "APPLICATION_SUPPORT_FOLDER") as! String + ) else { + throw CancellationError() + } + + if !FileManager.default.fileExists(atPath: supportURL.path) { + try? FileManager.default + .createDirectory(at: supportURL, withIntermediateDirectories: false) + } + let gitHubCopilotFolderURL = supportURL.appendingPathComponent("GitHub Copilot") + if !FileManager.default.fileExists(atPath: gitHubCopilotFolderURL.path) { + try? FileManager.default + .createDirectory(at: gitHubCopilotFolderURL, withIntermediateDirectories: false) + } + let supportFolderURL = gitHubCopilotFolderURL.appendingPathComponent("support") + if !FileManager.default.fileExists(atPath: supportFolderURL.path) { + try? FileManager.default + .createDirectory(at: supportFolderURL, withIntermediateDirectories: false) + } + let executableFolderURL = gitHubCopilotFolderURL.appendingPathComponent("executable") + if !FileManager.default.fileExists(atPath: executableFolderURL.path) { + try? FileManager.default + .createDirectory(at: executableFolderURL, withIntermediateDirectories: false) + } + + return (supportURL, gitHubCopilotFolderURL, executableFolderURL, supportFolderURL) + } +} + +@globalActor public enum GitHubCopilotSuggestionActor { + public actor TheActor {} + public static let shared = TheActor() +} + +public final class GitHubCopilotService: GitHubCopilotBaseService, + GitHubCopilotSuggestionServiceType, GitHubCopilotConversationServiceType, GitHubCopilotAuthServiceType +{ + + private var ongoingTasks = Set>() + private var serverNotificationHandler: ServerNotificationHandler = ServerNotificationHandlerImpl.shared + private var cancellables = Set() + + 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) + } + + @GitHubCopilotSuggestionActor + public func getCompletions( + fileURL: URL, + content: String, + originalContent: String, + cursorPosition: SuggestionBasic.CursorPosition, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool + ) async throws -> [CodeSuggestion] { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + + func sendRequest(maxTry: Int = 5) async throws -> [CodeSuggestion] { + do { + let completions = try await server + .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( + textDocument: .init(uri: fileURL.path, version: 1), + position: cursorPosition, + formattingOptions: .init( + tabSize: tabSize, + insertSpaces: !usesTabsForIndentation + ), + context: .init(triggerKind: .invoked) + ))) + .items + .compactMap { (item: _) -> CodeSuggestion? in + guard let range = item.range else { return nil } + let suggestion = CodeSuggestion( + id: item.command?.arguments?.first ?? UUID().uuidString, + text: item.insertText, + position: cursorPosition, + range: .init(start: range.start, end: range.end) + ) + return suggestion + } + try Task.checkCancellation() + return completions + } catch let error as ServerError { + switch error { + case .serverError: + // 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( + "Try getting suggestions again: \(GitHubCopilotError.languageServerError(error).localizedDescription)" + ) + try await Task.sleep(nanoseconds: 200_000_000) + return try await sendRequest(maxTry: maxTry - 1) + default: + break + } + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + 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 { + try Task.checkCancellation() + return try await sendRequest() + } catch let error as CancellationError { + if ongoingTasks.isEmpty { + await recoverContent() + } + throw error + } catch { + await recoverContent() + throw error + } + } + + ongoingTasks.insert(task) + + return try await task.value + } + + @GitHubCopilotSuggestionActor + public func createConversation(_ message: String, + workDoneToken: String, + workspaceFolder: String, + doc: Doc?, + skills: [String]) async throws { + let params = ConversationCreateParams(workDoneToken: workDoneToken, + turns: [ConversationTurn(request: message)], + capabilities: ConversationCreateParams.Capabilities( + skills: skills, + allSkills: false), + doc: doc, + source: .panel, + workspaceFolder: workspaceFolder) + do { + let _ = try await server.sendRequest( + GitHubCopilotRequest.CreateConversation(params: params) + ) + } catch { + print("Failed to create conversation. Error: \(error)") + throw error + } + } + + @GitHubCopilotSuggestionActor + public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?) async throws { + do { + let params = TurnCreateParams(workDoneToken: workDoneToken, conversationId: conversationId, message: message, doc: doc) + let _ = try await server.sendRequest( + GitHubCopilotRequest.CreateTurn(params: params) + ) + } catch { + print("Failed to create turn. Error: \(error)") + throw error + } + } + + @GitHubCopilotSuggestionActor + public func rateConversation(turnId: String, rating: ConversationRating) async throws { + do { + let params = ConversationRatingParams(turnId: turnId, rating: rating) + let _ = try await server.sendRequest( + GitHubCopilotRequest.ConversationRating(params: params) + ) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws { + let params = CopyCodeParams(turnId: turnId, codeBlockIndex: codeBlockIndex, copyType: copyType, copiedCharacters: copiedCharacters, totalCharacters: totalCharacters, copiedText: copiedText) + do { + let _ = try await server.sendRequest( + GitHubCopilotRequest.CopyCode(params: params) + ) + } catch { + print("Failed to register copied code block. Error: \(error)") + throw error + } + } + + @GitHubCopilotSuggestionActor + public func cancelRequest() async { + ongoingTasks.forEach { $0.cancel() } + ongoingTasks.removeAll() + await localProcessServer?.cancelOngoingTasks() + } + + @GitHubCopilotSuggestionActor + public func cancelProgress(token: String) async { + await localProcessServer?.cancelOngoingTask(token) + } + + @GitHubCopilotSuggestionActor + public func notifyShown(_ completion: CodeSuggestion) async { + _ = try? await server.sendRequest( + GitHubCopilotRequest.NotifyShown(completionUUID: completion.id) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyAccepted(_ completion: CodeSuggestion, acceptedLength: Int? = nil) async { + _ = try? await server.sendRequest( + GitHubCopilotRequest.NotifyAccepted(completionUUID: completion.id, acceptedLength: acceptedLength) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyRejected(_ completions: [CodeSuggestion]) async { + _ = try? await server.sendRequest( + GitHubCopilotRequest.NotifyRejected(completionUUIDs: completions.map(\.id)) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyOpenTextDocument( + fileURL: URL, + content: String + ) async throws { + let languageId = languageIdentifierFromFileURL(fileURL) + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Open \(uri), \(content.count)") + try await server.sendNotification( + .didOpenTextDocument( + DidOpenTextDocumentParams( + textDocument: .init( + uri: uri, + languageId: languageId.rawValue, + version: 0, + text: content + ) + ) + ) + ) + } + + @GitHubCopilotSuggestionActor + public func notifyChangeTextDocument( + fileURL: URL, + content: String, + version: Int + ) async throws { + let uri = "file://\(fileURL.path)" +// Logger.service.debug("Change \(uri), \(content.count)") + try await server.sendNotification( + .didChangeTextDocument( + DidChangeTextDocumentParams( + uri: uri, + version: version, + contentChange: .init( + range: nil, + rangeLength: nil, + text: content + ) + ) + ) + ) + } + + @GitHubCopilotSuggestionActor + 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))) + } + + @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))) + } + + @GitHubCopilotSuggestionActor + public func terminate() async { + // automatically handled + } + + @GitHubCopilotSuggestionActor + public func checkStatus() async throws -> GitHubCopilotAccountStatus { + do { + return try await server.sendRequest(GitHubCopilotRequest.CheckStatus()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func signInInitiate() async throws -> (verificationUri: String, userCode: String) { + do { + let result = try await server.sendRequest(GitHubCopilotRequest.SignInInitiate()) + return (result.verificationUri, result.userCode) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func signInConfirm(userCode: String) async throws + -> (username: String, status: GitHubCopilotAccountStatus) + { + do { + let result = try await server + .sendRequest(GitHubCopilotRequest.SignInConfirm(userCode: userCode)) + return (result.user, result.status) + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func signOut() async throws -> GitHubCopilotAccountStatus { + do { + return try await server.sendRequest(GitHubCopilotRequest.SignOut()).status + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } + + @GitHubCopilotSuggestionActor + public func version() async throws -> String { + do { + return try await server.sendRequest(GitHubCopilotRequest.GetVersion()).version + } catch let error as ServerError { + throw GitHubCopilotError.languageServerError(error) + } catch { + throw error + } + } +} + +extension InitializingServer: GitHubCopilotLSP { + func sendRequest(_ endpoint: E) async throws -> E.Response { + try await sendRequest(endpoint.request) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift new file mode 100644 index 0000000..1381747 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerNotificationHandler.swift @@ -0,0 +1,49 @@ +import Combine +import Foundation +import JSONRPC +import LanguageServerProtocol + +protocol ServerNotificationHandler { + var protocolProgressSubject: PassthroughSubject { get } + func handleNotification(_ notification: AnyJSONRPCNotification) +} + +class ServerNotificationHandlerImpl: ServerNotificationHandler { + public static let shared = ServerNotificationHandlerImpl() + var protocolProgressSubject: PassthroughSubject + var conversationProgressHandler: ConversationProgressHandler = ConversationProgressHandlerImpl.shared + var featureFlagNotifier: FeatureFlagNotifier = FeatureFlagNotifierImpl.shared + + init() { + self.protocolProgressSubject = PassthroughSubject() + } + + func handleNotification(_ notification: AnyJSONRPCNotification) { + let methodName = notification.method + + if let method = ServerNotification.Method(rawValue: methodName) { + switch method { + case .windowLogMessage: + break + case .protocolProgress: + if let data = try? JSONEncoder().encode(notification.params), + let progress = try? JSONDecoder().decode(ProgressParams.self, from: data) { + conversationProgressHandler.handleConversationProgress(progress) + } + default: + break + } + } else { + switch methodName { + case "featureFlagsNotification": + if let data = try? JSONEncoder().encode(notification.params), + let featureFlags = try? JSONDecoder().decode(FeatureFlags.self, from: data) { + featureFlagNotifier.handleFeatureFlagNotification(featureFlags) + } + break + default: + break + } + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift new file mode 100644 index 0000000..abf949f --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/SystemInfo.swift @@ -0,0 +1,50 @@ +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/FeatureFlagNotifier.swift b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift new file mode 100644 index 0000000..6729787 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/FeatureFlagNotifier.swift @@ -0,0 +1,37 @@ +import Combine +import SwiftUI + +public struct FeatureFlags: Hashable, Codable { + public var rt: Bool + public var sn: Bool + public var chat: Bool + public var x: Bool? + public var xc: Bool? +} + +public protocol FeatureFlagNotifier { + var featureFlags: FeatureFlags { get } + var featureFlagsDidChange: PassthroughSubject { get } + func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) +} + +public class FeatureFlagNotifierImpl: FeatureFlagNotifier { + 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()) { + self.featureFlags = featureFlags + self.featureFlagsDidChange = featureFlagsDidChange + } + + public func handleFeatureFlagNotification(_ featureFlags: FeatureFlags) { + self.featureFlags = featureFlags + self.featureFlags.chat = featureFlags.chat == true && featureFlags.xc == true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.featureFlagsDidChange.send(self.featureFlags) + } + } +} diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift new file mode 100644 index 0000000..ae43a45 --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift @@ -0,0 +1,46 @@ +import CopilotForXcodeKit +import Foundation +import ConversationServiceProvider +import BuiltinExtension + +public final class GitHubCopilotConversationService: ConversationServiceType { + + private let serviceLocator: ServiceLocator + + init(serviceLocator: ServiceLocator) { + self.serviceLocator = serviceLocator + } + + public func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + + return try await service.createConversation(request.content, + workDoneToken: request.workDoneToken, + workspaceFolder: request.workspaceFolder, + doc: nil, + skills: request.skills) + } + + public func createTurn(with conversationId: String, request: ConversationRequest, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + + return try await service.createTurn(request.content, workDoneToken: request.workDoneToken, conversationId: conversationId, doc: nil) + } + + public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + + await service.cancelProgress(token: workDoneToken) + } + + public func rateConversation(turnId: String, rating: ConversationRating, workspace: WorkspaceInfo) async throws { + guard let service = await serviceLocator.getService(from: workspace) else { return } + try await service.rateConversation(turnId: turnId, rating: rating) + } + + public func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws { + 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) + } +} + diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift new file mode 100644 index 0000000..f9f8a9b --- /dev/null +++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotSuggestionService.swift @@ -0,0 +1,107 @@ +import CopilotForXcodeKit +import Foundation +import SuggestionBasic +import Workspace + +public final class GitHubCopilotSuggestionService: SuggestionServiceType { + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: false + ) + } + + let serviceLocator: ServiceLocatorType + + init(serviceLocator: ServiceLocatorType) { + self.serviceLocator = serviceLocator + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CopilotForXcodeKit.CodeSuggestion] { + guard let service = await serviceLocator.getService(from: workspace) else { return [] } + return try await service.getCompletions( + fileURL: request.fileURL, + 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 + ).map(Self.convert) + } + + public func notifyAccepted( + _ suggestion: CopilotForXcodeKit.CodeSuggestion, + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyAccepted(Self.convert(suggestion)) + } + + public func notifyRejected( + _ suggestions: [CopilotForXcodeKit.CodeSuggestion], + workspace: WorkspaceInfo + ) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.notifyRejected(suggestions.map(Self.convert)) + } + + public func cancelRequest(workspace: WorkspaceInfo) async { + guard let service = await serviceLocator.getService(from: workspace) else { return } + await service.cancelRequest() + } + + static func convert( + _ suggestion: SuggestionBasic.CodeSuggestion + ) -> CopilotForXcodeKit.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } + + static func convert( + _ suggestion: CopilotForXcodeKit.CodeSuggestion + ) -> SuggestionBasic.CodeSuggestion { + .init( + id: suggestion.id, + text: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ) + ) + } +} + diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift new file mode 100644 index 0000000..f38243f --- /dev/null +++ b/Tool/Sources/Logger/Logger.swift @@ -0,0 +1,146 @@ +import Foundation +import os.log + +enum LogLevel: String { + case debug + case info + case error +} + +public final class Logger { + private let subsystem: String + private let category: String + private let osLog: OSLog + + public static let service = Logger(category: "Service") + public static let ui = Logger(category: "UI") + public static let client = Logger(category: "Client") + public static let updateChecker = Logger(category: "UpdateChecker") + public static let gitHubCopilot = Logger(category: "GitHubCopilot") + public static let langchain = Logger(category: "LangChain") + public static let retrieval = Logger(category: "Retrieval") + public static let license = Logger(category: "License") + public static let `extension` = Logger(category: "Extension") + public static let communicationBridge = Logger(category: "CommunicationBridge") + public static let debug = Logger(category: "Debug") + #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") + #endif + + public init(subsystem: String = "com.github.CopilotForXcode", category: String) { + self.subsystem = subsystem + self.category = category + osLog = OSLog(subsystem: subsystem, category: category) + } + + func log( + level: LogLevel, + message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + let osLogType: OSLogType + switch level { + case .debug: + osLogType = .debug + case .info: + osLogType = .info + case .error: + osLogType = .error + } + + os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) + } + + public func debug( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .debug, message: """ + \(message) + file: \(file) + line: \(line) + function: \(function) + """, file: file, line: line, function: function) + } + + public func info( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .info, message: message, file: file, line: line, function: function) + } + + public func error( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .error, message: message, file: file, line: line, function: function) + } + + public func error( + _ error: Error, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log( + level: .error, + message: error.localizedDescription, + file: file, + line: line, + function: function + ) + } + + public func signpostBegin( + name: StaticString, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) -> Signposter { + let poster = OSSignposter(logHandle: osLog) + let id = poster.makeSignpostID() + let state = poster.beginInterval(name, id: id) + return .init(log: osLog, id: id, name: name, signposter: poster, beginState: state) + } + + public struct Signposter { + let log: OSLog + let id: OSSignpostID + let name: StaticString + let signposter: OSSignposter + let state: OSSignpostIntervalState + + init( + log: OSLog, + id: OSSignpostID, + name: StaticString, + signposter: OSSignposter, + beginState: OSSignpostIntervalState + ) { + self.id = id + self.log = log + self.name = name + self.signposter = signposter + state = beginState + } + + public func end() { + signposter.endInterval(name, state) + } + + public func event(_ text: String) { + signposter.emitEvent(name, id: id, "\(text, privacy: .public)") + } + } +} + diff --git a/Tool/Sources/Preferences/AppStorage.swift b/Tool/Sources/Preferences/AppStorage.swift new file mode 100644 index 0000000..a5b3b21 --- /dev/null +++ b/Tool/Sources/Preferences/AppStorage.swift @@ -0,0 +1,262 @@ +import Foundation + +#if canImport(SwiftUI) + +import SwiftUI + +public extension AppStorage { + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Bool { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Double { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == URL { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Data { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value: RawRepresentable, Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } +} + +public extension AppStorage where Value: ExpressibleByNilLiteral { + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Bool? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == String? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Double? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Int? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == URL? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == Data? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +public extension AppStorage { + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + init( + _ keyPath: KeyPath + ) where K.Value == Value, Value == R?, R: RawRepresentable, R.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +// MARK: - Deprecated Key Accessor + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value: RawRepresentable, Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(wrappedValue: key.defaultValue, key.key, store: .shared) + } +} + +public extension AppStorage where Value: ExpressibleByNilLiteral { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Bool? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == String? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Double? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Int? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == URL? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == Data? { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +public extension AppStorage { + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } + + @available(*, deprecated, message: "This preference key is deprecated.") + init( + _ keyPath: KeyPath> + ) where Value == R?, R: RawRepresentable, R.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + self.init(key.key, store: .shared) + } +} + +#endif + diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift new file mode 100644 index 0000000..92c4dfb --- /dev/null +++ b/Tool/Sources/Preferences/Keys.swift @@ -0,0 +1,539 @@ +import Foundation + +public protocol UserDefaultPreferenceKey { + associatedtype Value + var defaultValue: Value { get } + var key: String { get } +} + +public struct PreferenceKey: UserDefaultPreferenceKey { + public let defaultValue: T + public let key: String + + public init(defaultValue: T, key: String) { + self.defaultValue = defaultValue + self.key = key + } +} + +public struct DeprecatedPreferenceKey { + public let defaultValue: T + public let key: String + + public init(defaultValue: T, key: String) { + self.defaultValue = defaultValue + self.key = key + } +} + +public struct FeatureFlag: UserDefaultPreferenceKey { + public let defaultValue: Bool + public let key: String + + public init(defaultValue: Bool, key: String) { + self.defaultValue = defaultValue + self.key = key + } +} + +public struct UserDefaultPreferenceKeys { + public init() {} + + // MARK: Quit XPC Service On Xcode And App Quit + + public let quitXPCServiceOnXcodeAndAppQuit = PreferenceKey( + defaultValue: true, + key: "QuitXPCServiceOnXcodeAndAppQuit" + ) + + // MARK: Suggestion Widget Position Mode + + public let suggestionWidgetPositionMode = PreferenceKey( + defaultValue: SuggestionWidgetPositionMode.fixedToBottom, + key: "SuggestionWidgetPositionMode" + ) + + // MARK: Widget Color Scheme + + public let widgetColorScheme = PreferenceKey( + defaultValue: WidgetColorScheme.system, + key: "WidgetColorScheme" + ) + + // MARK: Force Order Widget to Front + + public let forceOrderWidgetToFront = PreferenceKey( + defaultValue: true, + key: "ForceOrderWidgetToFront" + ) + + // MARK: Prefer Widget to Stay Inside Editor When Width Greater Than + + public let preferWidgetToStayInsideEditorWhenWidthGreaterThan = PreferenceKey( + defaultValue: 1400 as Double, + key: "PreferWidgetToStayInsideEditorWhenWidthGreaterThan" + ) + + // MARK: Hide Circular Widget + + public let hideCircularWidget = PreferenceKey( + defaultValue: true, + key: "HideCircularWidget" + ) + + public let showHideWidgetShortcutGlobally = PreferenceKey( + defaultValue: false, + key: "ShowHideWidgetShortcutGlobally" + ) + + // MARK: Update Channel + + public let installPrereleases = PreferenceKey( + defaultValue: false, + key: "InstallPrereleases" + ) + + // MARK: Completion Hint Shown + public let completionHintShown = PreferenceKey( + defaultValue: false, + key: "CompletionHintShown" + ) +} + +// MARK: - Prompt to Code + +public extension UserDefaultPreferenceKeys { + + var promptToCodeGenerateDescription: PreferenceKey { + .init(defaultValue: true, key: "PromptToCodeGenerateDescription") + } + + var promptToCodeGenerateDescriptionInUserPreferredLanguage: PreferenceKey { + .init(defaultValue: true, key: "PromptToCodeGenerateDescriptionInUserPreferredLanguage") + } + + var enableSenseScopeByDefaultInPromptToCode: PreferenceKey { + .init(defaultValue: false, key: "EnableSenseScopeByDefaultInPromptToCode") + } + + var promptToCodeCodeFontSize: PreferenceKey { + .init(defaultValue: 13, key: "PromptToCodeCodeFontSize") + } + + var hideCommonPrecedingSpacesInPromptToCode: PreferenceKey { + .init(defaultValue: true, key: "HideCommonPrecedingSpacesInPromptToCode") + } + + var wrapCodeInPromptToCode: PreferenceKey { + .init(defaultValue: true, key: "WrapCodeInPromptToCode") + } +} + +// MARK: - Suggestion + +public extension UserDefaultPreferenceKeys { + var oldSuggestionFeatureProvider: DeprecatedPreferenceKey { + .init(defaultValue: .gitHubCopilot, key: "SuggestionFeatureProvider") + } + + var suggestionFeatureProvider: PreferenceKey { + .init(defaultValue: .builtIn(.gitHubCopilot), key: "NewSuggestionFeatureProvider") + } + + var realtimeSuggestionToggle: PreferenceKey { + .init(defaultValue: true, key: "RealtimeSuggestionToggle") + } + + var suggestionDisplayCompactMode: PreferenceKey { + .init(defaultValue: true, key: "SuggestionDisplayCompactMode") + } + + var suggestionCodeFontSize: PreferenceKey { + .init(defaultValue: 13, key: "SuggestionCodeFontSize") + } + + var disableSuggestionFeatureGlobally: PreferenceKey { + .init(defaultValue: false, key: "DisableSuggestionFeatureGlobally") + } + + var suggestionFeatureEnabledProjectList: PreferenceKey<[String]> { + .init(defaultValue: [], key: "SuggestionFeatureEnabledProjectList") + } + + var suggestionFeatureDisabledLanguageList: PreferenceKey<[String]> { + .init(defaultValue: [], key: "SuggestionFeatureDisabledLanguageList") + } + + var hideCommonPrecedingSpacesInSuggestion: PreferenceKey { + .init(defaultValue: true, key: "HideCommonPrecedingSpacesInSuggestion") + } + + var suggestionPresentationMode: PreferenceKey { + .init(defaultValue: .nearbyTextCursor, key: "SuggestionPresentationMode") + } + + var realtimeSuggestionDebounce: PreferenceKey { + .init(defaultValue: 0.2, key: "RealtimeSuggestionDebounce") + } + + var acceptSuggestionWithTab: PreferenceKey { + .init(defaultValue: true, key: "AcceptSuggestionWithTab") + } + + var acceptSuggestionWithModifierCommand: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierCommand") + } + + var acceptSuggestionWithModifierOption: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierOption") + } + + var acceptSuggestionWithModifierControl: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierControl") + } + + var acceptSuggestionWithModifierShift: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierShift") + } + + var acceptSuggestionWithModifierOnlyForSwift: PreferenceKey { + .init(defaultValue: false, key: "SuggestionWithModifierOnlyForSwift") + } + + var dismissSuggestionWithEsc: PreferenceKey { + .init(defaultValue: true, key: "DismissSuggestionWithEsc") + } + + var isSuggestionSenseEnabled: PreferenceKey { + .init(defaultValue: false, key: "IsSuggestionSenseEnabled") + } + + var isSuggestionTypeInTheMiddleEnabled: PreferenceKey { + .init(defaultValue: true, key: "IsSuggestionTypeInTheMiddleEnabled") + } +} + +// MARK: - Chat + +public extension UserDefaultPreferenceKeys { + + var chatFontSize: PreferenceKey { + .init(defaultValue: 12, key: "ChatFontSize") + } + + var chatCodeFontSize: PreferenceKey { + .init(defaultValue: 12, key: "ChatCodeFontSize") + } + + var useGlobalChat: PreferenceKey { + .init(defaultValue: true, key: "UseGlobalChat") + } + + var embedFileContentInChatContextIfNoSelection: PreferenceKey { + .init(defaultValue: false, key: "EmbedFileContentInChatContextIfNoSelection") + } + + var maxFocusedCodeLineCount: PreferenceKey { + .init(defaultValue: 100, key: "MaxEmbeddableFileInChatContextLineCount") + } + + var useCodeScopeByDefaultInChatContext: DeprecatedPreferenceKey { + .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") + } + + + var wrapCodeInChatCodeBlock: PreferenceKey { + .init(defaultValue: true, key: "WrapCodeInChatCodeBlock") + } + + var enableFileScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: true, key: "EnableFileScopeByDefaultInChatContext") + } + + var enableCodeScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: true, key: "UseSelectionScopeByDefaultInChatContext") + } + + var enableSenseScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: false, key: "EnableSenseScopeByDefaultInChatContext") + } + + var enableProjectScopeByDefaultInChatContext: PreferenceKey { + .init(defaultValue: false, key: "EnableProjectScopeByDefaultInChatContext") + } + + var disableFloatOnTopWhenTheChatPanelIsDetached: PreferenceKey { + .init(defaultValue: true, key: "DisableFloatOnTopWhenTheChatPanelIsDetached") + } + + var keepFloatOnTopIfChatPanelAndXcodeOverlaps: PreferenceKey { + .init(defaultValue: true, key: "KeepFloatOnTopIfChatPanelAndXcodeOverlaps") + } +} + +// MARK: - Theme + +public extension UserDefaultPreferenceKeys { + var syncSuggestionHighlightTheme: PreferenceKey { + .init(defaultValue: true, key: "SyncSuggestionHighlightTheme") + } + + var syncPromptToCodeHighlightTheme: PreferenceKey { + .init(defaultValue: false, key: "SyncPromptToCodeHighlightTheme") + } + + var syncChatCodeHighlightTheme: PreferenceKey { + .init(defaultValue: false, key: "SyncChatCodeHighlightTheme") + } + + var codeForegroundColorLight: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeForegroundColorLight") + } + + var codeForegroundColorDark: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeForegroundColorDark") + } + + var codeBackgroundColorLight: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeBackgroundColorLight") + } + + var codeBackgroundColorDark: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CodeBackgroundColorDark") + } + + var currentLineBackgroundColorLight: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CurrentLineBackgroundColorLight") + } + + var currentLineBackgroundColorDark: PreferenceKey> { + .init(defaultValue: .init(nil), key: "CurrentLineBackgroundColorDark") + } + + var codeFontLight: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "CodeFontLight" + ) + } + + var codeFontDark: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "CodeFontDark" + ) + } + + var suggestionCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "SuggestionCodeFont" + ) + } + + var promptToCodeCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "PromptToCodeCodeFont" + ) + } + + var chatCodeFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "ChatCodeFont" + ) + } + + var terminalFont: PreferenceKey> { + .init( + defaultValue: .init(.init(nsFont: .monospacedSystemFont(ofSize: 12, weight: .regular))), + key: "TerminalCodeFont" + ) + } +} + +// MARK: - Bing Search + +public extension UserDefaultPreferenceKeys { + var bingSearchSubscriptionKey: PreferenceKey { + .init(defaultValue: "", key: "BingSearchSubscriptionKey") + } + + var bingSearchEndpoint: PreferenceKey { + .init( + defaultValue: "https://api.bing.microsoft.com/v7.0/search/", + key: "BingSearchEndpoint" + ) + } +} + +// MARK: - Custom Commands + +public extension UserDefaultPreferenceKeys { + var customCommands: PreferenceKey<[CustomCommand]> { + .init(defaultValue: [ + .init( + commandId: "BuiltInCustomCommandExplainSelection", + name: "Explain Selection", + feature: .chatWithSelection( + extraSystemPrompt: "", + prompt: "Explain the selected code concisely, step-by-step.", + useExtraSystemPrompt: true + ) + ), + .init( + commandId: "BuiltInCustomCommandAddDocumentationToSelection", + name: "Add Documentation to Selection", + feature: .promptToCode( + extraSystemPrompt: "", + prompt: "Add documentation on top of the code. Use triple slash if the language supports it.", + continuousMode: false, + generateDescription: true + ) + ) + ], key: "CustomCommands") + } + + var customChatCommands: PreferenceKey<[CustomCommand]> { + .init(defaultValue: [ + .init( + commandId: "BuiltInCustomCommandSendCodeToChat", + name: "Send Selected Code to Chat", + feature: .chatWithSelection( + extraSystemPrompt: "", + prompt: """ + ```{{active_editor_language}} + {{selected_code}} + ``` + """, + useExtraSystemPrompt: true + ) + ) + ], key: "CustomChatCommands") + } +} + +// MARK: - Feature Flags + +public extension UserDefaultPreferenceKeys { + var disableLazyVStack: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-DisableLazyVStack") + } + + var preCacheOnFileOpen: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-PreCacheOnFileOpen") + } + + var runNodeWithInteractiveLoggedInShell: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-RunNodeWithInteractiveLoggedInShell") + } + + var useCustomScrollViewWorkaround: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-UseCustomScrollViewWorkaround") + } + + var triggerActionWithAccessibilityAPI: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-TriggerActionWithAccessibilityAPI") + } + + var alwaysAcceptSuggestionWithAccessibilityAPI: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-AlwaysAcceptSuggestionWithAccessibilityAPI") + } + + var animationACrashSuggestion: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-AnimationACrashSuggestion") + } + + var animationBCrashSuggestion: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-AnimationBCrashSuggestion") + } + + var animationCCrashSuggestion: FeatureFlag { + .init(defaultValue: true, key: "FeatureFlag-AnimationCCrashSuggestion") + } + + var enableXcodeInspectorDebugMenu: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-EnableXcodeInspectorDebugMenu") + } + + var disableFunctionCalling: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-DisableFunctionCalling") + } + + var useUserDefaultsBaseAPIKeychain: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-UseUserDefaultsBaseAPIKeychain") + } + + var disableGitHubCopilotSettingsAutoRefreshOnAppear: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-DisableGitHubCopilotSettingsAutoRefreshOnAppear" + ) + } + + var disableEnhancedWorkspace: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-DisableEnhancedWorkspace" + ) + } + + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioning" + ) + } + + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer: FeatureFlag { + .init( + defaultValue: true, + key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer" + ) + } + + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-ToastForTheReasonWhyXcodeInspectorNeedsToBeRestarted" + ) + } + + var observeToAXNotificationWithDefaultMode: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-observeToAXNotificationWithDefaultMode") + } + + var useCloudflareDomainNameForLicenseCheck: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-UseCloudflareDomainNameForLicenseCheck") + } +} + +// MARK: - Feature + +public extension UserDefaultPreferenceKeys { + + var gitHubCopilotProxyHost: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotProxyHost") + } + + var gitHubCopilotProxyPort: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotProxyPort") + } + + var gitHubCopilotUseStrictSSL: PreferenceKey { + .init(defaultValue: true, key: "GitHubCopilotUseStrictSSL") + } + + var gitHubCopilotProxyUsername: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotProxyUsername") + } + + var gitHubCopilotProxyPassword: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotProxyPassword") + } + + var gitHubCopilotEnterpriseURI: PreferenceKey { + .init(defaultValue: "", key: "GitHubCopilotEnterpriseURI") + } +} diff --git a/Tool/Sources/Preferences/Types/CustomCommand.swift b/Tool/Sources/Preferences/Types/CustomCommand.swift new file mode 100644 index 0000000..b462e8a --- /dev/null +++ b/Tool/Sources/Preferences/Types/CustomCommand.swift @@ -0,0 +1,75 @@ +import CryptoKit +import Foundation + +public struct CustomCommand: Codable, Equatable { + /// The custom command feature. + /// + /// Keep everything optional so nothing will break when the format changes. + public enum Feature: Codable, Equatable { + /// Prompt to code. + case promptToCode( + extraSystemPrompt: String?, + prompt: String?, + continuousMode: Bool?, + generateDescription: Bool? + ) + /// Send message. + case chatWithSelection( + extraSystemPrompt: String?, + prompt: String?, + useExtraSystemPrompt: Bool? + ) + /// Custom chat. + case customChat(systemPrompt: String?, prompt: String?) + /// Single round dialog. + case singleRoundDialog( + systemPrompt: String?, + overwriteSystemPrompt: Bool?, + prompt: String?, + receiveReplyInNotification: Bool? + ) + } + + public var id: String { commandId ?? legacyId } + public var commandId: String? + public var name: String + public var feature: Feature + + public init(commandId: String, name: String, feature: Feature) { + self.commandId = commandId + self.name = name + self.feature = feature + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commandId = try container.decodeIfPresent(String.self, forKey: .commandId) + name = try container.decode(String.self, forKey: .name) + feature = (try? container + .decode(CustomCommand.Feature.self, forKey: .feature)) ?? .chatWithSelection( + extraSystemPrompt: "", + prompt: "", + useExtraSystemPrompt: false + ) + } + + var legacyId: String { + name.sha1HexString + } +} + +private extension Digest { + var bytes: [UInt8] { Array(makeIterator()) } + var data: Data { Data(bytes) } + + var hexStr: String { + bytes.map { String(format: "%02X", $0) }.joined() + } +} + +private extension String { + var sha1HexString: String { + Insecure.SHA1.hash(data: data(using: .utf8) ?? Data()).hexStr + } +} + diff --git a/Tool/Sources/Preferences/Types/Locale.swift b/Tool/Sources/Preferences/Types/Locale.swift new file mode 100644 index 0000000..6b50d82 --- /dev/null +++ b/Tool/Sources/Preferences/Types/Locale.swift @@ -0,0 +1,15 @@ +import Foundation + +public extension Locale { + static var availableLocalizedLocales: [String] { + let localizedLocales = Locale.isoLanguageCodes.compactMap { + Locale(identifier: "en-US").localizedString(forLanguageCode: $0) + } + .sorted() + return localizedLocales + } + + var languageName: String { + localizedString(forLanguageCode: languageCode ?? "") ?? "" + } +} diff --git a/Tool/Sources/Preferences/Types/PresentationMode.swift b/Tool/Sources/Preferences/Types/PresentationMode.swift new file mode 100644 index 0000000..66fd9a7 --- /dev/null +++ b/Tool/Sources/Preferences/Types/PresentationMode.swift @@ -0,0 +1,4 @@ +public enum PresentationMode: Int, CaseIterable { + case nearbyTextCursor = 0 + case floatingWidget = 1 +} diff --git a/Tool/Sources/Preferences/Types/StorableColors.swift b/Tool/Sources/Preferences/Types/StorableColors.swift new file mode 100644 index 0000000..b5c2d6c --- /dev/null +++ b/Tool/Sources/Preferences/Types/StorableColors.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct StorableColor: Codable, Equatable { + public var red: Double + public var green: Double + public var blue: Double + public var alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } +} + +#if canImport(SwiftUI) +import SwiftUI +public extension StorableColor { + var swiftUIColor: SwiftUI.Color { + SwiftUI.Color(CGColor(red: red, green: green, blue: blue, alpha: alpha)) + } +} +#endif + +#if canImport(AppKit) +import AppKit +public extension StorableColor { + var nsColor: NSColor { + NSColor( + srgbRed: CGFloat(red), + green: CGFloat(green), + blue: CGFloat(blue), + alpha: CGFloat(alpha) + ) + } +} +#endif + diff --git a/Tool/Sources/Preferences/Types/StorableFont.swift b/Tool/Sources/Preferences/Types/StorableFont.swift new file mode 100644 index 0000000..337ad62 --- /dev/null +++ b/Tool/Sources/Preferences/Types/StorableFont.swift @@ -0,0 +1,48 @@ +import AppKit +import Foundation + +public struct StorableFont: Codable, Equatable { + public var nsFont: NSFont + + public init(nsFont: NSFont) { + self.nsFont = nsFont + } + + public init(name: String, size: Double) { + if let font = NSFont(name: name, size: size) { + self.nsFont = font + } else { + self.nsFont = .monospacedSystemFont(ofSize: size, weight: .regular) + } + } + + public enum CodingKeys: String, CodingKey { + case nsFont + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fontData = try container.decode(Data.self, forKey: .nsFont) + guard let nsFont = try NSKeyedUnarchiver.unarchivedObject( + ofClass: NSFont.self, + from: fontData + ) else { + throw DecodingError.dataCorruptedError( + forKey: .nsFont, + in: container, + debugDescription: "Failed to decode NSFont" + ) + } + self.nsFont = nsFont + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let fontData = try NSKeyedArchiver.archivedData( + withRootObject: nsFont, + requiringSecureCoding: false + ) + try container.encode(fontData, forKey: .nsFont) + } +} + diff --git a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift new file mode 100644 index 0000000..9067405 --- /dev/null +++ b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum BuiltInSuggestionFeatureProvider: Int, CaseIterable, Codable { + case gitHubCopilot +} + +public enum SuggestionFeatureProvider: RawRepresentable, Hashable { + case builtIn(BuiltInSuggestionFeatureProvider) + case `extension`(name: String, bundleIdentifier: String) + + enum Storage: Codable { + case builtIn(BuiltInSuggestionFeatureProvider) + case `extension`(name: String, bundleIdentifier: String) + } + + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let value = try? JSONDecoder().decode(Storage.self, from: data) + else { return nil } + + switch value { + case let .builtIn(provider): + self = .builtIn(provider) + case let .extension(name, bundleIdentifier): + self = .extension(name: name, bundleIdentifier: bundleIdentifier) + } + } + + public var rawValue: String { + let storage: Storage = switch self { + case let .builtIn(provider): .builtIn(provider) + case let .extension(name, bundleIdentifier): + .extension(name: name, bundleIdentifier: bundleIdentifier) + } + if let data = try? JSONEncoder().encode(storage) { + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + diff --git a/Tool/Sources/Preferences/Types/SuggestionWidgetPositionMode.swift b/Tool/Sources/Preferences/Types/SuggestionWidgetPositionMode.swift new file mode 100644 index 0000000..8fd3143 --- /dev/null +++ b/Tool/Sources/Preferences/Types/SuggestionWidgetPositionMode.swift @@ -0,0 +1,4 @@ +public enum SuggestionWidgetPositionMode: Int, CaseIterable { + case fixedToBottom = 0 + case alignToTextCursor = 1 +} diff --git a/Tool/Sources/Preferences/Types/WidgetColorScheme.swift b/Tool/Sources/Preferences/Types/WidgetColorScheme.swift new file mode 100644 index 0000000..aa459c4 --- /dev/null +++ b/Tool/Sources/Preferences/Types/WidgetColorScheme.swift @@ -0,0 +1,5 @@ +public enum WidgetColorScheme: Int, CaseIterable { + case system = 0 + case light = 1 + case dark = 2 +} diff --git a/Tool/Sources/Preferences/UserDefaults.swift b/Tool/Sources/Preferences/UserDefaults.swift new file mode 100644 index 0000000..6971134 --- /dev/null +++ b/Tool/Sources/Preferences/UserDefaults.swift @@ -0,0 +1,305 @@ +import AppKit +import Configs +import Foundation + +public protocol UserDefaultsType { + func value(forKey: String) -> Any? + func set(_ value: Any?, forKey: String) +} + +public extension UserDefaults { + static var shared = UserDefaults(suiteName: userDefaultSuiteName)! + + static func setupDefaultSettings() { + shared.setupDefaultValue(for: \.quitXPCServiceOnXcodeAndAppQuit) + shared.setupDefaultValue(for: \.realtimeSuggestionToggle) + shared.setupDefaultValue(for: \.realtimeSuggestionDebounce) + shared.setupDefaultValue(for: \.suggestionPresentationMode) + shared.setupDefaultValue(for: \.widgetColorScheme) + shared.setupDefaultValue(for: \.customCommands) + shared.setupDefaultValue( + for: \.suggestionFeatureProvider, + defaultValue: .builtIn(shared.deprecatedValue(for: \.oldSuggestionFeatureProvider)) + ) + shared.setupDefaultValue( + for: \.promptToCodeCodeFontSize, + defaultValue: shared.value(for: \.suggestionCodeFontSize) + ) + shared.setupDefaultValue( + for: \.suggestionCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.suggestionCodeFontSize), + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.codeFontLight, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: 12, + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.codeFontDark, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: 12, + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.promptToCodeCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.promptToCodeCodeFontSize), + weight: .regular + ))) + ) + shared.setupDefaultValue( + for: \.chatCodeFont, + defaultValue: .init(.init(nsFont: .monospacedSystemFont( + ofSize: shared.value(for: \.chatCodeFontSize), + weight: .regular + ))) + ) + } +} + +extension UserDefaults: UserDefaultsType {} + +public protocol UserDefaultsStorable {} + +extension Int: UserDefaultsStorable {} +extension Double: UserDefaultsStorable {} +extension Bool: UserDefaultsStorable {} +extension String: UserDefaultsStorable {} +extension Data: UserDefaultsStorable {} +extension URL: UserDefaultsStorable {} + +extension Array: RawRepresentable where Element: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Element].self, from: data) + else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "[]" + } + return result + } +} + +public struct UserDefaultsStorageBox: RawRepresentable { + public let value: Element + + public init(_ value: Element) { + self.value = value + } + + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(Element.self, from: data) + else { + return nil + } + value = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(value), + let result = String(data: data, encoding: .utf8) + else { + return "" + } + return result + } +} + +extension UserDefaultsStorageBox: Equatable where Element: Equatable {} + +public extension UserDefaultsType { + // MARK: Normal Types + + func value( + for keyPath: KeyPath + ) -> K.Value where K.Value: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K.Value) ?? key.defaultValue + } + + func set( + _ value: K.Value, + for keyPath: KeyPath + ) where K.Value: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(value, forKey: key.key) + } + + func setupDefaultValue( + for keyPath: KeyPath + ) where K.Value: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(key.defaultValue, forKey: key.key) + } + } + + func setupDefaultValue( + for keyPath: KeyPath, + defaultValue: K.Value + ) where K.Value: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(defaultValue, forKey: key.key) + } + } + + // MARK: Raw Representable + + func value( + for keyPath: KeyPath + ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K.Value(rawValue: rawValue) ?? key.defaultValue + } + + func value( + for keyPath: KeyPath + ) -> K.Value where K.Value: RawRepresentable, K.Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K.Value(rawValue: rawValue) ?? key.defaultValue + } + + func value( + for keyPath: KeyPath + ) -> V where K.Value == UserDefaultsStorageBox { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue.value + } + return (K.Value(rawValue: rawValue) ?? key.defaultValue).value + } + + func set( + _ value: K.Value, + for keyPath: KeyPath + ) where K.Value: RawRepresentable, K.Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(value.rawValue, forKey: key.key) + } + + func set( + _ value: K.Value, + for keyPath: KeyPath + ) where K.Value: RawRepresentable, K.Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(value.rawValue, forKey: key.key) + } + + func set( + _ value: V, + for keyPath: KeyPath + ) where K.Value == UserDefaultsStorageBox { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + set(UserDefaultsStorageBox(value).rawValue, forKey: key.key) + } + + func setupDefaultValue( + for keyPath: KeyPath, + defaultValue: K.Value? = nil + ) where K.Value: RawRepresentable, K.Value.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) + } + } + + func setupDefaultValue( + for keyPath: KeyPath, + defaultValue: K.Value? = nil + ) where K.Value: RawRepresentable, K.Value.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + if value(forKey: key.key) == nil { + set(defaultValue?.rawValue ?? key.defaultValue.rawValue, forKey: key.key) + } + } +} + +// MARK: - Deprecated Key Accessor + +public extension UserDefaultsType { + // MARK: Normal Types + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + // MARK: Raw Representable + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + func deprecatedValue( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + +public extension UserDefaultsType { + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: UserDefaultsStorable { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + return (value(forKey: key.key) as? K) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == String { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? String else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } + + @available(*, deprecated, message: "This preference key is deprecated.") + func value( + for keyPath: KeyPath> + ) -> K where K: RawRepresentable, K.RawValue == Int { + let key = UserDefaultPreferenceKeys()[keyPath: keyPath] + guard let rawValue = value(forKey: key.key) as? Int else { + return key.defaultValue + } + return K(rawValue: rawValue) ?? key.defaultValue + } +} + diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift new file mode 100644 index 0000000..26c9711 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift @@ -0,0 +1,200 @@ +import DebounceFunction +import Foundation +import Perception +import SwiftUI + +public struct AsyncCodeBlock: View { + private struct Constants { + static let paddingLeading = 5.0 + static let paddingBottom = 10.0 + static let paddingTrailing = 10.0 + } + + @Environment(\.colorScheme) var colorScheme + @Binding var isExpanded: Bool + @State private var isHovering: Bool = false + @AppStorage(\.completionHintShown) var completionHintShown + + let code: String + let language: String + let startLineIndex: Int + let scenario: String + let firstLineIndent: Double + let lineHeight: Double + let font: NSFont + let proposedForegroundColor: Color? + let proposedBackgroundColor: Color? + let currentLineBackgroundColor: Color? + let dimmedCharacterCount: Int + let droppingLeadingSpaces: Bool + let isPanelDisplayed: Bool + + public init( + code: String, + language: String, + startLineIndex: Int, + scenario: String, + firstLineIndent: Double, + lineHeight: Double, + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color?, + proposedBackgroundColor: Color?, + currentLineBackgroundColor: Color?, + dimmedCharacterCount: Int, + isExpanded: Binding, + isPanelDisplayed: Bool + ) { + self.code = code + self.startLineIndex = startLineIndex + self.language = language + self.scenario = scenario + self.firstLineIndent = firstLineIndent + self.lineHeight = lineHeight + self.font = font + self.proposedForegroundColor = proposedForegroundColor + self.proposedBackgroundColor = proposedBackgroundColor + self.currentLineBackgroundColor = currentLineBackgroundColor + self.dimmedCharacterCount = dimmedCharacterCount + self.droppingLeadingSpaces = droppingLeadingSpaces + self._isExpanded = isExpanded + self.isPanelDisplayed = isPanelDisplayed + } + + var foregroundColor: Color { + if let proposedForegroundColor = proposedForegroundColor { + return proposedForegroundColor + } + return colorScheme == .light ? .black.opacity(0.85) : .white.opacity(0.85) + } + + var foregroundTextColor: Color { + return foregroundColor.opacity(0.6) + } + + var backgroundColor: Color { + if let proposedBackgroundColor = proposedBackgroundColor { + return proposedBackgroundColor + } + return colorScheme == .dark ? Color(red: 0.1216, green: 0.1216, blue: 0.1412) : .white + } + + var fontHeight: Double { + (font.ascender + abs(font.descender)).rounded(.down) + } + + var lineSpacing: Double { + lineHeight - fontHeight + } + + var expandedIndent: Double { + let lines = code.splitByNewLine() + guard let firstLine = lines.first else { return 0 } + let existing = String(firstLine.prefix(dimmedCharacterCount)) + let attr = NSAttributedString(string: existing, attributes: [.font: font]) + return firstLineIndent - attr.size().width + } + + var hintText: String { + if isExpanded { + return "Press โŒฅโ‡ฅ to accept full suggestion" + } + return "Hold โŒฅ for full suggestion" + } + + @ScaledMetric var ellipsisPadding: CGFloat = 5 + + @ViewBuilder + var contentView: some View { + let lines = code.splitByNewLine() + if let firstLine = lines.first { + let firstLineTrimmed = firstLine + .dropFirst(dimmedCharacterCount) + HStack() { + HStack(alignment: .center, spacing: 10) { + Text(firstLineTrimmed) + .foregroundColor(foregroundTextColor) + .lineSpacing(lineSpacing) // This only has effect if a line wraps + if lines.count > 1 { + Image(systemName: "ellipsis") + .renderingMode(.template) + .foregroundColor(foregroundTextColor) + .padding(.horizontal, ellipsisPadding) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(isExpanded ? 0.1 : 0.4)) + .frame(height: fontHeight * 0.75) + ) + .popover(isPresented: $isHovering) { + Text(hintText) + .font(.body) + .padding(8) + .fixedSize() + } + .task { + isHovering = !completionHintShown + completionHintShown = true + } + } + } + .background(currentLineBackgroundColor ?? backgroundColor) + .padding(.leading, firstLineIndent) + .frame(minHeight: lineHeight) + .onHover { hovering in + guard hovering != isHovering else { return } + withAnimation { + isHovering = hovering + } + } + Spacer() + } + } + + if isExpanded && lines.count > 1 { + HStack() { + CustomScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.dropFirst()), id: \.self) { line in + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(line) + .foregroundColor(foregroundTextColor) + .lineSpacing(lineSpacing) + } + .frame(minHeight: lineHeight) + } + } + } + .padding(EdgeInsets( + top: 0, + leading: Constants.paddingLeading, + bottom: Constants.paddingBottom, + trailing: Constants.paddingTrailing + )) + .background(backgroundColor) + .cornerRadius(10) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(foregroundColor.opacity(0.2), lineWidth: 1)) // border + .shadow(color: Color.black.opacity(0.2), radius: 8.0, x: 1, y: 1) + .onHover { hovering in + guard hovering != isHovering else { return } + withAnimation { + isHovering = hovering + } + } + Spacer() + } + .padding(.leading, expandedIndent - Constants.paddingLeading) + } + } + + public var body: some View { + if isPanelDisplayed { + WithPerceptionTracking { + VStack(spacing: 0) { + contentView + } + .font(.init(font)) + .background(Color.clear) + } + } + } +} diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift new file mode 100644 index 0000000..6fd852e --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift @@ -0,0 +1,129 @@ +import Preferences +import SwiftUI + +public struct CodeBlock: View { + public let code: String + public let language: String + public let startLineIndex: Int + public let scenario: String + public let colorScheme: ColorScheme + public let commonPrecedingSpaceCount: Int + public let highlightedCode: [NSAttributedString] + public let firstLinePrecedingSpaceCount: Int + public let font: NSFont + public let droppingLeadingSpaces: Bool + public let proposedForegroundColor: Color? + public let wrapCode: Bool + + public init( + code: String, + language: String, + startLineIndex: Int, + scenario: String, + colorScheme: ColorScheme, + firstLinePrecedingSpaceCount: Int = 0, + font: NSFont, + droppingLeadingSpaces: Bool, + proposedForegroundColor: Color?, + wrapCode: Bool = true + ) { + self.code = code + self.language = language + self.startLineIndex = startLineIndex + self.scenario = scenario + self.colorScheme = colorScheme + self.droppingLeadingSpaces = droppingLeadingSpaces + self.firstLinePrecedingSpaceCount = firstLinePrecedingSpaceCount + self.font = font + self.proposedForegroundColor = proposedForegroundColor + self.wrapCode = wrapCode + let padding = firstLinePrecedingSpaceCount > 0 + ? String(repeating: " ", count: firstLinePrecedingSpaceCount) + : "" + let result = Self.highlight( + code: padding + code, + language: language, + scenario: scenario, + colorScheme: colorScheme, + font: font, + droppingLeadingSpaces: droppingLeadingSpaces + ) + commonPrecedingSpaceCount = result.commonLeadingSpaceCount + highlightedCode = result.code + } + + var foregroundColor: Color { + proposedForegroundColor ?? (colorScheme == .dark ? .white : .black) + } + + public var body: some View { + VStack(spacing: 2) { + ForEach(0.. 0 { + Text("\(commonPrecedingSpaceCount + 1)") + .padding(.top, -12) + .font(.footnote) + .foregroundStyle(foregroundColor) + .opacity(0.3) + } + } + } + } + } + .foregroundColor(.white) + .font(.init(font)) + .padding(.leading, 4) + .padding([.trailing, .top, .bottom]) + } + + static func highlight( + code: String, + language: String, + scenario: String, + colorScheme: ColorScheme, + font: NSFont, + droppingLeadingSpaces: Bool + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + return CodeHighlighting.highlighted( + code: code, + language: language, + scenario: scenario, + brightMode: colorScheme != .dark, + droppingLeadingSpaces: droppingLeadingSpaces, + font: font + ) + } +} + +// MARK: - Preview + +struct CodeBlock_Previews: PreviewProvider { + static var previews: some View { + CodeBlock( + code: """ + let foo = Foo() + let bar = Bar() + """, + language: "swift", + startLineIndex: 0, + scenario: "", + colorScheme: .dark, + firstLinePrecedingSpaceCount: 0, + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + droppingLeadingSpaces: true, + proposedForegroundColor: nil + ) + } +} + diff --git a/Tool/Sources/SharedUIComponents/CopyButton.swift b/Tool/Sources/SharedUIComponents/CopyButton.swift new file mode 100644 index 0000000..022e84d --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CopyButton.swift @@ -0,0 +1,39 @@ +import AppKit +import SwiftUI + +public struct CopyButton: View { + public var copy: () -> Void + @State var isCopied = false + + public init(copy: @escaping () -> Void) { + self.copy = copy + } + + public var body: some View { + Button(action: { + withAnimation(.linear(duration: 0.1)) { + isCopied = true + } + copy() + Task { + try await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation(.linear(duration: 0.1)) { + isCopied = false + } + } + }) { + 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) + } + .buttonStyle(.borderless) + } +} diff --git a/Tool/Sources/SharedUIComponents/CustomScrollView.swift b/Tool/Sources/SharedUIComponents/CustomScrollView.swift new file mode 100644 index 0000000..0eb486f --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CustomScrollView.swift @@ -0,0 +1,66 @@ +import AppKit +import Combine +import Preferences +import SwiftUI + +public struct CustomScrollViewHeightPreferenceKey: SwiftUI.PreferenceKey { + public static var defaultValue: Double = 0 + public static func reduce(value: inout Double, nextValue: () -> Double) { + value = nextValue() + value + } +} + +public struct CustomScrollViewUpdateHeightModifier: ViewModifier { + public func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + Color.clear + .preference( + key: CustomScrollViewHeightPreferenceKey.self, + value: proxy.size.height + ) + } + } + } +} + +/// Used to workaround a SwiftUI bug. https://github.com/intitni/CopilotForXcode/issues/122 +public struct CustomScrollView: View { + @ViewBuilder var content: () -> Content + @State var height: Double = 10 + @AppStorage(\.useCustomScrollViewWorkaround) var useNSScrollViewWrapper + + public init(content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + if useNSScrollViewWrapper { + List { + content() + .listRowInsets(EdgeInsets(top: 0, leading: -8, bottom: 0, trailing: -8)) + .modifier(CustomScrollViewUpdateHeightModifier()) + } + .listStyle(.plain) + .modify { view in + if #available(macOS 13.0, *) { + view.listRowSeparator(.hidden).listSectionSeparator(.hidden) + } else { + view + } + } + .frame(idealHeight: max(10, height)) + .onPreferenceChange(CustomScrollViewHeightPreferenceKey.self) { newHeight in + Task { @MainActor in + height = newHeight + } + } + } else { + ScrollView { + content() + } + } + } +} + diff --git a/Tool/Sources/SharedUIComponents/CustomTextEditor.swift b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift new file mode 100644 index 0000000..d001da8 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/CustomTextEditor.swift @@ -0,0 +1,157 @@ +import SwiftUI + +public struct AutoresizingCustomTextEditor: View { + @Binding public var text: String + public let font: NSFont + public let isEditable: Bool + public let maxHeight: Double + public let onSubmit: () -> Void + public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] + + public init( + text: Binding, + font: NSFont, + isEditable: Bool, + maxHeight: Double, + onSubmit: @escaping () -> Void, + completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) + -> [String] = { _, _, _ in [] } + ) { + _text = text + self.font = font + self.isEditable = isEditable + self.maxHeight = maxHeight + self.onSubmit = onSubmit + self.completions = completions + } + + 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) + } + } +} + +public struct CustomTextEditor: NSViewRepresentable { + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + @Binding public var text: String + public let font: NSFont + public let isEditable: Bool + public let onSubmit: () -> Void + public var completions: (_ text: String, _ words: [String], _ range: NSRange) -> [String] + + public init( + text: Binding, + font: NSFont, + isEditable: Bool = true, + onSubmit: @escaping () -> Void, + completions: @escaping (_ text: String, _ words: [String], _ range: NSRange) + -> [String] = { _, _, _ in [] } + ) { + _text = text + self.font = font + self.isEditable = isEditable + self.onSubmit = onSubmit + self.completions = completions + } + + 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 + textView.font = font + textView.allowsUndo = true + textView.drawsBackground = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + + return context.coordinator.theTextView + } + + 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() + } +} + +public extension CustomTextEditor { + class Coordinator: NSObject, NSTextViewDelegate { + var view: CustomTextEditor + var theTextView = NSTextView.scrollableTextView() + var affectedCharRange: NSRange? + var completions: (String, [String], _ range: NSRange) -> [String] = { _, _, _ in [] } + + init(_ view: CustomTextEditor) { + self.view = view + } + + public func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { + return + } + + view.text = textView.string + textView.complete(nil) + } + + public func textView( + _ textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + if commandSelector == #selector(NSTextView.insertNewline(_:)) { + if let event = NSApplication.shared.currentEvent, + !event.modifierFlags.contains(.shift), + event.keyCode == 36 // enter + { + view.onSubmit() + return true + } + } + + return false + } + + public func textView( + _ textView: NSTextView, + shouldChangeTextIn affectedCharRange: NSRange, + replacementString: String? + ) -> 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/DownvoteButton.swift b/Tool/Sources/SharedUIComponents/DownvoteButton.swift new file mode 100644 index 0000000..33d6ec9 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/DownvoteButton.swift @@ -0,0 +1,32 @@ +import AppKit +import SwiftUI +import ConversationServiceProvider + +public struct DownvoteButton: View { + public var downvote: (ConversationRating) -> Void + @State var isSelected = false + + public init(downvote: @escaping (ConversationRating) -> Void) { + self.downvote = downvote + } + + public var body: some View { + Button(action: { + isSelected = !isSelected + isSelected ? downvote(.unhelpful) : downvote(.unrated) + }) { + Image(systemName: isSelected ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .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) + } + .buttonStyle(.borderless) + } +} diff --git a/Tool/Sources/SharedUIComponents/DynamicHeightTextInFormWorkaround.swift b/Tool/Sources/SharedUIComponents/DynamicHeightTextInFormWorkaround.swift new file mode 100644 index 0000000..4a5efd3 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/DynamicHeightTextInFormWorkaround.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct DynamicHeightTextInFormWorkaroundModifier: ViewModifier { + func body(content: Content) -> some View { + HStack(spacing: 0) { + content + Spacer() + } + .fixedSize(horizontal: false, vertical: true) + } +} + +public extension View { + func dynamicHeightTextInFormWorkaround() -> some View { + modifier(DynamicHeightTextInFormWorkaroundModifier()) + } +} diff --git a/Tool/Sources/SharedUIComponents/FontPicker.swift b/Tool/Sources/SharedUIComponents/FontPicker.swift new file mode 100644 index 0000000..2f91c9d --- /dev/null +++ b/Tool/Sources/SharedUIComponents/FontPicker.swift @@ -0,0 +1,89 @@ +import AppKit +import Foundation +import Preferences +import SwiftUI + +public struct FontPicker: View { + @State var fontManagerDelegate: FontManagerDelegate? + @Binding var font: NSFont + let label: Label + + public init(font: Binding, @ViewBuilder label: () -> Label) { + _font = font + self.label = label() + } + + public var body: some View { + if #available(macOS 13.0, *) { + LabeledContent { + button + } label: { + label + } + } else { + HStack { + label + button + } + } + } + + var button: some View { + Button { + if NSFontPanel.shared.isVisible { + NSFontPanel.shared.orderOut(nil) + } + + self.fontManagerDelegate = FontManagerDelegate(font: font) { + self.font = $0 + } + NSFontManager.shared.target = self.fontManagerDelegate + NSFontPanel.shared.setPanelFont(self.font, isMultiple: false) + NSFontPanel.shared.orderBack(nil) + } label: { + HStack { + Text(font.fontName) + + Text(" - ") + + Text(font.pointSize, format: .number.precision(.fractionLength(1))) + + Text("pt") + + Spacer().frame(width: 30) + + Image(systemName: "textformat") + .frame(width: 13) + .scaledToFit() + } + } + } + + final class FontManagerDelegate: NSObject { + let font: NSFont + let onSelection: (NSFont) -> Void + init(font: NSFont, onSelection: @escaping (NSFont) -> Void) { + self.font = font + self.onSelection = onSelection + } + + @objc func changeFont(_ sender: NSFontManager) { + onSelection(sender.convert(font)) + } + } +} + +public extension FontPicker { + init(font: Binding>, @ViewBuilder label: () -> Label) { + _font = Binding( + get: { font.wrappedValue.value.nsFont }, + set: { font.wrappedValue = .init(StorableFont(nsFont: $0)) } + ) + self.label = label() + } +} + +#Preview { + FontPicker(font: .constant(.systemFont(ofSize: 15))) { + Text("Font") + } + .padding() +} + diff --git a/Tool/Sources/SharedUIComponents/SettingsDivider.swift b/Tool/Sources/SharedUIComponents/SettingsDivider.swift new file mode 100644 index 0000000..15db820 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SettingsDivider.swift @@ -0,0 +1,42 @@ +import SwiftUI + +public struct SettingsDivider: View { + let title: Title? + + public init(_ title: Title) { + self.title = title + } + + public var body: some View { + if let title { + HStack { + VStack { + Divider() + } + title + .foregroundStyle(.secondary) + .font(.subheadline) + .zIndex(2) + VStack { + Divider() + } + } + .padding(.vertical, 8) + } else { + Divider() + .padding(.vertical, 8) + } + } +} + +extension SettingsDivider where Title == Text { + public init(_ title: String) { + self.title = Text(title) + } +} + +extension SettingsDivider where Title == EmptyView { + public init() { + self.title = nil + } +} diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift new file mode 100644 index 0000000..5993508 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -0,0 +1,159 @@ +import AppKit +import Foundation +import Highlightr +import SuggestionBasic +import SwiftUI + +public enum CodeHighlighting { + public static func highlightedCodeBlock( + code: String, + language: String, + scenario: String, + brightMode: Bool, + font: NSFont + ) -> NSAttributedString { + var language = language + // Workaround: Highlightr uses a different identifier for Objective-C. + if language.lowercased().hasPrefix("objective"), language.lowercased().hasSuffix("c") { + language = "objectivec" + } + func unhighlightedCode() -> NSAttributedString { + return NSAttributedString( + string: code, + attributes: [ + .foregroundColor: brightMode ? NSColor.black : NSColor.white, + .font: font, + ] + ) + } + guard let highlighter = Highlightr() else { + return unhighlightedCode() + } + highlighter.setTheme(to: { + let mode = brightMode ? "light" : "dark" + if scenario.isEmpty { + return mode + } + return "\(scenario)-\(mode)" + }()) + highlighter.theme.setCodeFont(font) + guard let formatted = highlighter.highlight(code, as: language) else { + return unhighlightedCode() + } + if formatted.string == "undefined" { + return unhighlightedCode() + } + return formatted + } + + public static func highlighted( + code: String, + language: String, + scenario: String, + brightMode: Bool, + droppingLeadingSpaces: Bool, + font: NSFont, + replaceSpacesWithMiddleDots: Bool = false + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let formatted = highlightedCodeBlock( + code: code, + language: language, + scenario: scenario, + brightMode: brightMode, + font: font + ) + let middleDotColor = brightMode + ? NSColor.black.withAlphaComponent(0.1) + : NSColor.white.withAlphaComponent(0.1) + return convertToCodeLines( + formatted, + middleDotColor: middleDotColor, + droppingLeadingSpaces: droppingLeadingSpaces, + replaceSpacesWithMiddleDots: replaceSpacesWithMiddleDots + ) + } + + public static func convertToCodeLines( + _ formattedCode: NSAttributedString, + middleDotColor: NSColor, + droppingLeadingSpaces: Bool, + replaceSpacesWithMiddleDots: Bool = false + ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) { + let input = formattedCode.string + func isEmptyLine(_ line: String) -> Bool { + if line.isEmpty { return true } + guard let regex = try? NSRegularExpression(pattern: #"^\s*\n?$"#) else { return false } + if regex.firstMatch( + in: line, + options: [], + range: NSMakeRange(0, line.utf16.count) + ) != nil { + return true + } + return false + } + + let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false) + .map { String($0) } + let commonLeadingSpaceCount = { + if !droppingLeadingSpaces { return 0 } + let split = separatedInput + var result = 0 + outerLoop: for i in stride(from: 40, through: 4, by: -4) { + for line in split { + if isEmptyLine(line) { continue } + if i >= line.count { continue outerLoop } + if !line.hasPrefix(.init(repeating: " ", count: i)) { continue outerLoop } + } + result = i + break + } + return result + }() + var output = [NSAttributedString]() + var start = 0 + for sub in separatedInput { + let range = NSMakeRange(start, sub.utf16.count) + let attributedString = formattedCode.attributedSubstring(from: range) + let mutable = NSMutableAttributedString(attributedString: attributedString) + + // remove leading spaces + if commonLeadingSpaceCount > 0 { + let leadingSpaces = String(repeating: " ", count: commonLeadingSpaceCount) + if mutable.string.hasPrefix(leadingSpaces) { + mutable.replaceCharacters( + in: NSRange(location: 0, length: commonLeadingSpaceCount), + with: "" + ) + } else if isEmptyLine(mutable.string) { + mutable.mutableString.setString("") + } + } + + if replaceSpacesWithMiddleDots { + // use regex to replace all spaces to a middle dot + do { + let regex = try NSRegularExpression(pattern: "[ ]*", options: []) + let result = regex.matches( + in: mutable.string, + range: NSRange(location: 0, length: mutable.mutableString.length) + ) + for r in result { + let range = r.range + mutable.replaceCharacters( + in: range, + with: String(repeating: "ยท", count: range.length) + ) + mutable.addAttributes([ + .foregroundColor: middleDotColor, + ], range: range) + } + } catch {} + } + output.append(mutable) + start += range.length + 1 + } + return (output, commonLeadingSpaceCount) + } +} + diff --git a/Tool/Sources/SharedUIComponents/UpvoteButton.swift b/Tool/Sources/SharedUIComponents/UpvoteButton.swift new file mode 100644 index 0000000..40c985c --- /dev/null +++ b/Tool/Sources/SharedUIComponents/UpvoteButton.swift @@ -0,0 +1,32 @@ +import AppKit +import SwiftUI +import ConversationServiceProvider + +public struct UpvoteButton: View { + public var upvote: (ConversationRating) -> Void + @State var isSelected = false + + public init(upvote: @escaping (ConversationRating) -> Void) { + self.upvote = upvote + } + + public var body: some View { + Button(action: { + isSelected = !isSelected + isSelected ? upvote(.helpful) : upvote(.unrated) + }) { + Image(systemName: isSelected ? "hand.thumbsup.fill" : "hand.thumbsup") + .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) + } + .buttonStyle(.borderless) + } +} diff --git a/Tool/Sources/SharedUIComponents/View+Modify.swift b/Tool/Sources/SharedUIComponents/View+Modify.swift new file mode 100644 index 0000000..5982077 --- /dev/null +++ b/Tool/Sources/SharedUIComponents/View+Modify.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public extension View { + @ViewBuilder func modify(@ViewBuilder transform: (Self) -> Content) + -> some View + { + transform(self) + } +} + diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift new file mode 100644 index 0000000..bd124fc --- /dev/null +++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift @@ -0,0 +1,36 @@ +import Foundation +import CodableWrappers + +public struct CodeSuggestion: Codable, Equatable { + public init( + id: String, + text: String, + position: CursorPosition, + range: CursorRange + ) { + self.text = text + self.position = position + self.id = id + self.range = range + middlewareComments = [] + } + + public static func == (lhs: CodeSuggestion, rhs: CodeSuggestion) -> Bool { + return lhs.text == rhs.text + && lhs.position == rhs.position + && lhs.id == rhs.id + && lhs.range == rhs.range + } + + /// The new code to be inserted and the original code on the first line. + public var text: String + /// The position of the cursor before generating the completion. + public var position: CursorPosition + /// An id. + public var id: String + /// The range of the original code that should be replaced. + public var range: CursorRange + /// A place to store comments inserted by middleware for debugging use. + @FallbackDecoding public var middlewareComments: [String] +} + diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift new file mode 100644 index 0000000..8518b8b --- /dev/null +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -0,0 +1,167 @@ +import Foundation +import Parsing + +public struct EditorInformation { + public struct LineAnnotation { + public var type: String + public var line: Int + public var message: String + } + + public struct SourceEditorContent { + /// The content of the source editor. + public var content: String + /// The content of the source editor in lines. Every line should ends with `\n`. + public var lines: [String] + /// The selection ranges of the source editor. + public var selections: [CursorRange] + /// The cursor position of the source editor. + public var cursorPosition: CursorPosition + /// The cursor position as offset. + public var cursorOffset: Int + /// Line annotations of the source editor. + public var lineAnnotations: [LineAnnotation] + + public var selectedContent: String { + if let range = selections.first { + let startIndex = min( + max(0, range.start.line), + lines.endIndex - 1 + ) + let endIndex = min( + max(startIndex, range.end.line), + lines.endIndex - 1 + ) + let selectedContent = lines[startIndex...endIndex] + return selectedContent.joined() + } + return "" + } + + public init( + content: String, + lines: [String], + selections: [CursorRange], + cursorPosition: CursorPosition, + cursorOffset: Int, + lineAnnotations: [String] + ) { + self.content = content + self.lines = lines + self.selections = selections + self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset + self.lineAnnotations = lineAnnotations.map(EditorInformation.parseLineAnnotation) + } + } + + public let editorContent: SourceEditorContent? + public let selectedContent: String + public let selectedLines: [String] + public let documentURL: URL + public let workspaceURL: URL + public let projectRootURL: URL + public let relativePath: String + public let language: CodeLanguage + + public init( + editorContent: SourceEditorContent?, + selectedContent: String, + selectedLines: [String], + documentURL: URL, + workspaceURL: URL, + projectRootURL: URL, + relativePath: String, + language: CodeLanguage + ) { + self.editorContent = editorContent + self.selectedContent = selectedContent + self.selectedLines = selectedLines + self.documentURL = documentURL + self.workspaceURL = workspaceURL + self.projectRootURL = projectRootURL + self.relativePath = relativePath + self.language = language + } + + public func code(in range: CursorRange) -> String { + return EditorInformation.code(in: editorContent?.lines ?? [], inside: range).code + } + + public static func lines(in code: [String], containing range: CursorRange) -> [String] { + guard !code.isEmpty else { return [] } + guard range.start.line <= range.end.line else { return [] } + let startIndex = min(max(0, range.start.line), code.endIndex - 1) + let endIndex = min(max(startIndex, range.end.line), code.endIndex - 1) + guard startIndex <= endIndex else { return [] } + let selectedLines = code[startIndex...endIndex] + return Array(selectedLines) + } + + public static func code( + in code: [String], + inside range: CursorRange, + ignoreColumns: Bool = false + ) -> (code: String, lines: [String]) { + guard range.start <= range.end else { return ("", []) } + + let rangeLines = lines(in: code, containing: range) + if ignoreColumns { + return (rangeLines.joined(), rangeLines) + } + var content = rangeLines + if !content.isEmpty { + let lastLine = content[content.endIndex - 1] + let droppedEndIndex = lastLine.utf16.index( + lastLine.utf16.startIndex, + offsetBy: range.end.character, + limitedBy: lastLine.utf16.endIndex + ) ?? lastLine.utf16.endIndex + content[content.endIndex - 1] = if droppedEndIndex > lastLine.utf16.startIndex { + String(lastLine[.. LineAnnotation { + let lineAnnotationParser = Parse(input: Substring.self) { + PrefixUpTo(":") + ":" + PrefixUpTo(":") + ":" + Int.parser() + Prefix(while: { _ in true }) + }.map { (prefix: Substring, _: Substring, line: Int, message: Substring) in + let type = String(prefix.split(separator: " ").first ?? prefix) + return LineAnnotation( + type: type.trimmingCharacters(in: .whitespacesAndNewlines), + line: line, + message: message.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + do { + return try lineAnnotationParser.parse(annotation[...]) + } catch { + return .init(type: "", line: 0, message: annotation) + } + } +} + diff --git a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift new file mode 100644 index 0000000..f4345fd --- /dev/null +++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift @@ -0,0 +1,75 @@ +import LanguageServerProtocol + +/// Line starts at 0. +public typealias CursorPosition = LanguageServerProtocol.Position + +public extension CursorPosition { + static let zero = CursorPosition(line: 0, character: 0) + static var outOfScope: CursorPosition { .init(line: -1, character: -1) } + + var readableText: String { + return "[\(line + 1), \(character)]" + } +} + +public struct CursorRange: Codable, Hashable, Sendable, Equatable, CustomStringConvertible { + public static let zero = CursorRange(start: .zero, end: .zero) + + public var start: CursorPosition + public var end: CursorPosition + + public init(start: Position, end: Position) { + self.start = start + self.end = end + } + + public init(startPair: (Int, Int), endPair: (Int, Int)) { + start = CursorPosition(startPair) + end = CursorPosition(endPair) + } + + public func contains(_ position: CursorPosition) -> Bool { + return position >= start && position <= end + } + + public func contains(_ range: CursorRange) -> Bool { + return range.start >= start && range.end <= end + } + + public func strictlyContains(_ range: CursorRange) -> Bool { + return range.start > start && range.end < end + } + + public func intersects(_ other: LSPRange) -> Bool { + return contains(other.start) || contains(other.end) + } + + public var isEmpty: Bool { + return start == end + } + + public var isOneLine: Bool { + return start.line == end.line + } + + /// The number of lines in the range. + public var lineCount: Int { + return end.line - start.line + 1 + } + + public static func == (lhs: CursorRange, rhs: CursorRange) -> Bool { + return lhs.start == rhs.start && lhs.end == rhs.end + } + + public var description: String { + return "\(start.readableText) - \(end.readableText)" + } +} + +public extension CursorRange { + static var outOfScope: CursorRange { .init(start: .outOfScope, end: .outOfScope) } + static func cursor(_ position: CursorPosition) -> CursorRange { + return .init(start: position, end: position) + } +} + diff --git a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift new file mode 100644 index 0000000..a047883 --- /dev/null +++ b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift @@ -0,0 +1,274 @@ +import Foundation +import LanguageServerProtocol + +public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { + case builtIn(LanguageIdentifier) + case plaintext + case other(String) + + public var rawValue: String { + switch self { + case let .builtIn(language): + return language.rawValue + case .plaintext: + return "plaintext" + case let .other(language): + return language + } + } + + public var hashValue: Int { + rawValue.hashValue + } + + public init?(rawValue: String) { + if let language = LanguageIdentifier(rawValue: rawValue) { + self = .builtIn(language) + } else if rawValue == "txt" || rawValue.isEmpty { + self = .plaintext + } else { + self = .other(rawValue) + } + } + + public init(fileURL: URL) { + self = languageIdentifierFromFileURL(fileURL) + } + + public init(filePath: String) { + self = languageIdentifierFromFileURL(URL(fileURLWithPath: filePath)) + } + + public static var allCases: [CodeLanguage] { + var all = LanguageIdentifier.allCases.map(CodeLanguage.builtIn) + all.append(.plaintext) + return all + } +} + +public extension LanguageIdentifier { + /// Copied from https://github.com/github/linguist/blob/master/lib/linguist/languages.yml [MIT] + var fileExtensions: [String] { + switch self { + case .abap: + return ["abap"] + case .windowsbat: + return ["bat", "cmd"] + case .bibtex: + return ["bib", "bibtex"] + case .clojure: + return ["clj", "boot", "cl2", "cljc", "cljs", "cljs.hl", "cljscm", "cljx", "hic"] + case .coffeescript: + return ["coffee", "_coffee", "cjsx", "cson", "iced"] + case .c: + return ["c", "cats", "idc"] + case .cpp: + return ["cpp", "c++", "cc", "cp", "cxx", "h++", "hh", "hpp", "hxx", "inl", "ino", "ipp", + "ixx", "re", "tcc", "tpp"] + case .csharp: + return ["cs", "cake", "csx", "linq"] + case .css: + return ["css"] + case .diff: + return ["diff", "patch"] + case .dart: + return ["dart"] + case .dockerfile: + return ["dockerfile"] + case .elixir: + return ["ex", "exs"] + case .erlang: + return ["erl", "es", "escript", "hrl"] + case .fsharp: + return ["fs", "fsi", "fsx"] + case .gitcommit: + return [] + case .gitrebase: + return [] + case .go: + return ["go"] + case .groovy: + return ["groovy", "grt", "gtpl", "gvy"] + case .handlebars: + return ["handlebars", "hbs"] + case .html: + return ["html", "hta", "htm", "inc", "xht", "xhtml"] + case .ini: + return ["ini", "cfg", "dof", "lektorproject", "prefs", "pro", "properties", "url"] + case .java: + return ["java"] + case .javascript: + return ["js", "_js", "bones", "es6", "frag", "gs", "jake", "jsb", "jsfl", "jsm", "jss", + "njs", "pac", "sjs", "ssjs", "xsjs", "xsjslib"] + case .javascriptreact: + return ["jsx"] + case .json: + return ["json"] + case .latex: + return ["tex"] + case .less: + return ["less"] + case .lua: + return ["lua"] + case .makefile: + return ["mak", "d", "mk"] + case .markdown: + return ["md", "livemd", "markdown", "mkd", "mkdn", "mkdown", "ronn", "scd", "workbook"] + case .objc: + return ["m", "h"] + case .objcpp: + return ["mm"] + case .perl: + return ["pl", "perl", "ph", "plx", /* "pm", */ "pod", "psgi" /* "t" */ ] + case .perl6: + return ["6pl", "6pm", "nqp", "p6", "p6l", "p6m", /* "pl", */ "pl6", "pm", "pm6", "t"] + case .php: + return ["php", "aw", "ctp", "php3", "php4", "php5", "phpt"] + case .powershell: + return ["ps1", "psd1", "psm1"] + case .pug: + return ["jade", "pug"] + case .python: + return ["py", "cgi", "gyp", "lmi", "pyde", "pyp", "pyt", "pyw", "tac", "wsgi", "xpy"] + case .r: + return ["r", "rd", "rsx"] + case .razor: + return ["cshtml", "razor"] + case .ruby: + return ["rb", "builder", "gemspec", "god", "irbrc", "jbuilder", "mspec", "pluginspec", + "podspec", "rabl", "rake", "rbuild", "rbw", "rbx", "ru", "ruby", "thor", + "watchr"] + case .rust: + return ["rs"] + case .scss: + return ["scss"] + case .sass: + return ["sass"] + case .scala: + return ["scala", "sbt", "sc"] + case .shaderlab: + return ["shader"] + case .shellscript: + return ["sh"] + case .sql: + return ["sql", "cql", "ddl", "prc", "tab", "udf", "viw"] + case .swift: + return ["swift", "xcplayground", "xcplaygroundpage", "playground"] + case .typescript: + return ["ts"] + case .typescriptreact: + return ["tsx"] + case .tex: + return [ /* "tex", */ "aux", "bbx", "cbx", "cls", "dtx", "ins", "lbx", "ltx", "mkii", + "mkiv", "mkvi", "sty", "toc"] + case .vb: + return [ + "vb", + "bas", +// "cls", + "frm", + "frx", + "vba", + "vbhtml", + "vbs", + ] + case .xml: + return [ + "xml", + "ant", + "axml", + "ccxml", + "clixml", + "cproject", + "csproj", + "ct", + "dita", + "ditamap", + "ditaval", + "dll.config", + "filters", + "fsproj", + "fxml", + "glade", + "grxml", + "ivy", + "jelly", + "kml", + "launch", + "mxml", + "nproj", + "nuspec", + "odd", + "osm", + "plist", +// "pluginspec", + "ps1xml", + "psc1", + "pt", + "rdf", + "rss", + "scxml", + "srdf", + "storyboard", + "stTheme", + "sublime-snippet", + "targets", + "tmCommand", + "tml", + "tmLanguage", + "tmPreferences", + "tmSnippet", + "tmTheme", + "ui", + "urdf", + "vbproj", + "vcxproj", + "vxml", + "wsdl", + "wsf", + "wxi", + "wxl", + "wxs", + "x3d", + "xacro", + "xaml", + "xib", + "xlf", + "xliff", + "xmi", + "xml.dist", + "xsd", + "xul", + "zcml", + ] + case .xsl: + return ["xsl"] + case .yaml: + return [ + "yml", + "reek", + "rviz", + "yaml", + ] + } + } +} + +let fileExtensionToLanguageId = { + var dict = [String: LanguageIdentifier]() + for languageId in LanguageIdentifier.allCases { + for e in languageId.fileExtensions { + dict[e] = languageId + } + } + return dict +}() + +public func languageIdentifierFromFileURL(_ fileURL: URL) -> CodeLanguage { + let fileExtension = fileURL.pathExtension + if let builtIn = fileExtensionToLanguageId[fileExtension] { + return .builtIn(builtIn) + } + return .init(rawValue: fileExtension) ?? .plaintext +} + diff --git a/Tool/Sources/SuggestionBasic/Modification.swift b/Tool/Sources/SuggestionBasic/Modification.swift new file mode 100644 index 0000000..c4547e8 --- /dev/null +++ b/Tool/Sources/SuggestionBasic/Modification.swift @@ -0,0 +1,44 @@ +import Foundation + +public enum Modification: Codable, Equatable { + case deleted(ClosedRange) + case inserted(Int, [String]) +} + +public extension [String] { + mutating func apply(_ modifications: [Modification]) { + for modification in modifications { + switch modification { + case let .deleted(range): + if isEmpty { break } + let removingRange = range.lowerBound..<(range.upperBound + 1) + removeSubrange(removingRange.clamped(to: 0.. Array { + var newArray = self + newArray.apply(modifications) + return newArray + } +} + +public extension NSMutableArray { + func apply(_ modifications: [Modification]) { + for modification in modifications { + switch modification { + case let .deleted(range): + if count == 0 { break } + let newRange = range.clamped(to: 0...(count - 1)) + removeObjects(in: NSRange(newRange)) + case let .inserted(index, strings): + for string in strings.reversed() { + insert(string, at: Swift.min(count, index)) + } + } + } + } +} diff --git a/Tool/Sources/SuggestionBasic/String+LineEnding.swift b/Tool/Sources/SuggestionBasic/String+LineEnding.swift new file mode 100644 index 0000000..ddbe990 --- /dev/null +++ b/Tool/Sources/SuggestionBasic/String+LineEnding.swift @@ -0,0 +1,52 @@ +import Foundation + +public extension String { + /// The line ending of the string. + /// + /// We are pretty safe to just check the last character here, in most case, a line ending + /// will be in the end of the string. + /// + /// For other situations, we can assume that they are "\n". + var lineEnding: Character { + if let last, last.isNewline { return last } + return "\n" + } + + func splitByNewLine( + omittingEmptySubsequences: Bool = true, + fast: Bool = true + ) -> [Substring] { + if fast { + let lineEndingInText = lineEnding + return split( + separator: lineEndingInText, + omittingEmptySubsequences: omittingEmptySubsequences + ) + } + return split( + omittingEmptySubsequences: omittingEmptySubsequences, + whereSeparator: \.isNewline + ) + } + + /// Break a string into lines. + func breakLines( + proposedLineEnding: String? = nil, + appendLineBreakToLastLine: Bool = false + ) -> [String] { + let lineEndingInText = lineEnding + let lineEnding = proposedLineEnding ?? String(lineEndingInText) + // Split on character for better performance. + let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) + var all = [String]() + for (index, line) in lines.enumerated() { + if !appendLineBreakToLastLine, index == lines.endIndex - 1 { + all.append(String(line)) + } else { + all.append(String(line) + lineEnding) + } + } + return all + } +} + diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift new file mode 100644 index 0000000..e69e29d --- /dev/null +++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift @@ -0,0 +1,71 @@ +import Foundation +import SuggestionBasic + +public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddleware { + public init() {} + + public func getSuggestion( + _ 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) + if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil } + return suggestion + } + } + + static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) { + var text = suggestion.text[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + suggestion.text = String(text) + } + + static func checkIfSuggestionHasNoEffect( + _ suggestion: CodeSuggestion, + request: SuggestionRequest + ) -> Bool { + // We only check suggestions that are on a single line. + if suggestion.range.isOneLine { + let line = suggestion.range.start.line + if line >= 0, line < request.lines.count { + let replacingText = request.lines[line] + + let start = suggestion.range.start.character + let end = suggestion.range.end.character + if let endIndex = replacingText.utf16.index( + replacingText.startIndex, + offsetBy: end, + limitedBy: replacingText.endIndex + ), + let startIndex = replacingText.utf16.index( + replacingText.startIndex, + offsetBy: start, + limitedBy: endIndex + ), + startIndex < endIndex + { + let replacingRange = startIndex.. [CodeSuggestion] + + func getSuggestion( + _ request: SuggestionRequest, + configuration: SuggestionServiceConfiguration, + next: Next + ) async throws -> [CodeSuggestion] +} + +public enum SuggestionServiceMiddlewareContainer { + static var builtInMiddlewares: [SuggestionServiceMiddleware] = [ + DisabledLanguageSuggestionServiceMiddleware(), + PostProcessingSuggestionServiceMiddleware() + ] + + static var customMiddlewares: [SuggestionServiceMiddleware] = [] + + public static var middlewares: [SuggestionServiceMiddleware] { + builtInMiddlewares + customMiddlewares + } + + public static func addMiddleware(_ middleware: SuggestionServiceMiddleware) { + customMiddlewares.append(middleware) + } +} + +public struct DisabledLanguageSuggestionServiceMiddleware: SuggestionServiceMiddleware { + public init() {} + + public func getSuggestion( + _ 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 { + public init() {} + + public func getSuggestion( + _ 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 new file mode 100644 index 0000000..2426561 --- /dev/null +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceProvider.swift @@ -0,0 +1,79 @@ +import AppKit +import struct CopilotForXcodeKit.SuggestionServiceConfiguration +import struct CopilotForXcodeKit.WorkspaceInfo +import Foundation +import Preferences +import SuggestionBasic +import UserDefaultsObserver + +public struct SuggestionRequest { + public var fileURL: URL + public var relativePath: String + public var content: String + public var originalContent: String + public var lines: [String] + public var cursorPosition: CursorPosition + public var cursorOffset: Int + public var tabSize: Int + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var relevantCodeSnippets: [RelevantCodeSnippet] + + public init( + fileURL: URL, + relativePath: String, + content: String, + originalContent: String, + lines: [String], + cursorPosition: CursorPosition, + cursorOffset: Int, + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool, + relevantCodeSnippets: [RelevantCodeSnippet] + ) { + self.fileURL = fileURL + self.relativePath = relativePath + self.content = content + self.originalContent = content + self.lines = lines + self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset + self.tabSize = tabSize + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.relevantCodeSnippets = relevantCodeSnippets + } +} + +public struct RelevantCodeSnippet: Codable { + public var content: String + public var priority: Int + public var filePath: String + + public init(content: String, priority: Int, filePath: String) { + self.content = content + self.priority = priority + self.filePath = filePath + } +} + +public protocol SuggestionServiceProvider { + func getSuggestions( + _ request: SuggestionRequest, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async throws -> [CodeSuggestion] + func notifyAccepted( + _ suggestion: CodeSuggestion, + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func notifyRejected( + _ suggestions: [CodeSuggestion], + workspaceInfo: CopilotForXcodeKit.WorkspaceInfo + ) async + func cancelRequest(workspaceInfo: CopilotForXcodeKit.WorkspaceInfo) async + + var configuration: SuggestionServiceConfiguration { get async } +} + +public typealias SuggestionServiceConfiguration = CopilotForXcodeKit.SuggestionServiceConfiguration diff --git a/Tool/Sources/Terminal/Terminal.swift b/Tool/Sources/Terminal/Terminal.swift new file mode 100644 index 0000000..89812c4 --- /dev/null +++ b/Tool/Sources/Terminal/Terminal.swift @@ -0,0 +1,200 @@ +import AppKit +import Foundation + +public protocol TerminalType { + func streamCommand( + _ command: String, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String] + ) -> AsyncThrowingStream + + func runCommand( + _ command: String, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String] + ) async throws -> String + + func terminate() async + func writeInput(_ input: String) async + var isRunning: Bool { get } +} + +public final class Terminal: TerminalType, @unchecked Sendable { + var process: Process? + var outputPipe: Pipe? + var inputPipe: Pipe? + + public var isRunning: Bool { process?.isRunning ?? false } + + public struct TerminationError: Error { + public let reason: Process.TerminationReason + public let status: Int32 + } + + public init() {} + + func getEnvironmentVariables() -> [String: String] { + let env = ProcessInfo.processInfo.environment + .merging(["LANG": "en_US.UTF-8"], uniquingKeysWith: { $1 }) + return env + } + + public func streamCommand( + _ command: String = "/bin/bash", + arguments: [String], + currentDirectoryURL: URL? = nil, + environment: [String: String] + ) -> AsyncThrowingStream { + self.process?.terminate() + let process = Process() + self.process = process + + process.launchPath = command + process.currentDirectoryURL = currentDirectoryURL + process.arguments = arguments + process.environment = getEnvironmentVariables() + .merging(environment, uniquingKeysWith: { $1 }) + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + self.outputPipe = outputPipe + + let inputPipe = Pipe() + process.standardInput = inputPipe + self.inputPipe = inputPipe + + var continuation: AsyncThrowingStream.Continuation! + let contentStream = AsyncThrowingStream { cont in + continuation = cont + } + + Task { [continuation, self] in + let notificationCenter = NotificationCenter.default + let notifications = notificationCenter.notifications( + named: FileHandle.readCompletionNotification, + object: outputPipe.fileHandleForReading + ) + for await notification in notifications { + let userInfo = notification.userInfo + if let data = userInfo?[NSFileHandleNotificationDataItem] as? Data, + let content = String(data: data, encoding: .utf8), + !content.isEmpty + { + continuation?.yield(content) + } + if !(self.process?.isRunning ?? false) { + let reason = self.process?.terminationReason ?? .exit + let status = self.process?.terminationStatus ?? 1 + if let output = (self.process?.standardOutput as? Pipe)?.fileHandleForReading + .readDataToEndOfFile(), + let content = String(data: output, encoding: .utf8), + !content.isEmpty + { + continuation?.yield(content) + } + + if status == 0 { + continuation?.finish() + } else { + continuation?.finish(throwing: TerminationError( + reason: reason, + status: status + )) + } + break + } + Task { @MainActor in + outputPipe.fileHandleForReading.readInBackgroundAndNotify(forModes: [.common]) + } + } + } + + Task { @MainActor in + outputPipe.fileHandleForReading.readInBackgroundAndNotify(forModes: [.common]) + } + + do { + try process.run() + } catch { + continuation.finish(throwing: error) + } + + return contentStream + } + + public func runCommand( + _ command: String = "/bin/bash", + arguments: [String], + currentDirectoryURL: URL? = nil, + environment: [String: String] + ) async throws -> String { + let process = Process() + process.launchPath = command + process.currentDirectoryURL = currentDirectoryURL + process.arguments = arguments + process.environment = getEnvironmentVariables() + .merging(environment, uniquingKeysWith: { $1 }) + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + self.outputPipe = outputPipe + + let inputPipe = Pipe() + process.standardInput = inputPipe + self.inputPipe = inputPipe + + return try await withUnsafeThrowingContinuation { continuation in + do { + process.terminationHandler = { process in + do { + if let data = try outputPipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + if process.terminationStatus == 0 { + continuation.resume(returning: content) + } else { + struct LocalizedTerminationError: Error, LocalizedError { + let terminationError: TerminationError + let errorDescription: String? + } + continuation.resume(throwing: LocalizedTerminationError( + terminationError: .init( + reason: process.terminationReason, + status: process.terminationStatus + ), + errorDescription: content + )) + } + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } + + public func writeInput(_ input: String) { + guard let data = input.data(using: .utf8) else { + return + } + + inputPipe?.fileHandleForWriting.write(data) + inputPipe?.fileHandleForWriting.closeFile() + } + + public func terminate() async { + process?.terminate() + process = nil + } +} + diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift new file mode 100644 index 0000000..2abcca9 --- /dev/null +++ b/Tool/Sources/Toast/Toast.swift @@ -0,0 +1,140 @@ +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI + +public enum ToastType { + case info + case warning + case error +} + +public struct ToastKey: EnvironmentKey { + public static var defaultValue: (String, ToastType) -> Void = { _, _ in } +} + +public extension EnvironmentValues { + var toast: (String, ToastType) -> Void { + get { self[ToastKey.self] } + set { self[ToastKey.self] = newValue } + } +} + +public struct ToastControllerDependencyKey: DependencyKey { + public static let liveValue = ToastController(messages: []) +} + +public extension DependencyValues { + var toastController: ToastController { + get { self[ToastControllerDependencyKey.self] } + set { self[ToastControllerDependencyKey.self] = newValue } + } + + var toast: (String, ToastType) -> Void { + return { content, type in + toastController.toast(content: content, type: type, namespace: nil) + } + } + + var namespacedToast: (String, ToastType, String) -> Void { + return { + content, type, namespace in + toastController.toast(content: content, type: type, namespace: namespace) + } + } +} + +public class ToastController: ObservableObject { + public struct Message: Identifiable, Equatable { + public var namespace: String? + public var id: UUID + public var type: ToastType + public var content: Text + public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) { + self.namespace = namespace + self.id = id + self.type = type + self.content = content + } + } + + @Published public var messages: [Message] = [] + + public init(messages: [Message]) { + 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)) + + 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 } + } + } + } +} + +@Reducer +public struct Toast { + public typealias Message = ToastController.Message + + @ObservableState + public struct State: Equatable { + var isObservingToastController = false + public var messages: [Message] = [] + + public init(messages: [Message] = []) { + self.messages = messages + } + } + + public enum Action: Equatable { + case start + case updateMessages([Message]) + case toast(String, ToastType, String?) + } + + @Dependency(\.toastController) var toastController + + struct CancelID: Hashable {} + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .start: + guard !state.isObservingToastController else { return .none } + state.isObservingToastController = true + return .run { send in + let stream = AsyncStream<[Message]> { continuation in + let cancellable = toastController.$messages.sink { newValue in + continuation.yield(newValue) + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + for await newValue in stream { + try Task.checkCancellation() + await send(.updateMessages(newValue), animation: .linear(duration: 0.2)) + } + }.cancellable(id: CancelID(), cancelInFlight: true) + case let .updateMessages(messages): + state.messages = messages + return .none + case let .toast(content, type, namespace): + toastController.toast(content: content, type: type, namespace: namespace) + return .none + } + } + } +} + diff --git a/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift b/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift new file mode 100644 index 0000000..62ecce3 --- /dev/null +++ b/Tool/Sources/UserDefaultsObserver/UserDefaultsObserver.swift @@ -0,0 +1,36 @@ +import Foundation + +public final class UserDefaultsObserver: NSObject { + public var onChange: (() -> Void)? + private weak var object: NSObject? + private let keyPaths: [String] + + public init( + object: NSObject, + forKeyPaths keyPaths: [String], + context: UnsafeMutableRawPointer? + ) { + self.object = object + self.keyPaths = keyPaths + super.init() + for keyPath in keyPaths { + object.addObserver(self, forKeyPath: keyPath, options: .new, context: context) + } + } + + deinit { + for keyPath in keyPaths { + object?.removeObserver(self, forKeyPath: keyPath) + } + } + + public override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + onChange?() + } +} + diff --git a/Tool/Sources/Workspace/FileSaveWatcher.swift b/Tool/Sources/Workspace/FileSaveWatcher.swift new file mode 100644 index 0000000..97c0142 --- /dev/null +++ b/Tool/Sources/Workspace/FileSaveWatcher.swift @@ -0,0 +1,39 @@ +import Foundation + +final class FileSaveWatcher { + let url: URL + var fileHandle: FileHandle? + var source: DispatchSourceFileSystemObject? + var changeHandler: () -> Void = {} + + init(fileURL: URL) { + url = fileURL + startup() + } + + deinit { + source?.cancel() + } + + func startup() { + if let source = source { + source.cancel() + } + + fileHandle = try? FileHandle(forReadingFrom: url) + if let fileHandle = fileHandle { + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileHandle.fileDescriptor, + eventMask: .link, + queue: .main + ) + + source?.setEventHandler { [weak self] in + self?.changeHandler() + self?.startup() + } + + source?.resume() + } + } +} diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift new file mode 100644 index 0000000..2b31c4a --- /dev/null +++ b/Tool/Sources/Workspace/Filespace.swift @@ -0,0 +1,172 @@ +import Dependencies +import Foundation +import SuggestionBasic + +public protocol FilespacePropertyKey { + associatedtype Value + static func createDefaultValue() -> Value +} + +public final class FilespacePropertyValues { + private var storage: [ObjectIdentifier: Any] = [:] + + @WorkspaceActor + public subscript(_ key: K.Type) -> K.Value { + get { + if let value = storage[ObjectIdentifier(key)] as? K.Value { + return value + } + let value = key.createDefaultValue() + storage[ObjectIdentifier(key)] = value + return value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } +} + +public struct FilespaceCodeMetadata: Equatable { + public var uti: String? + public var tabSize: Int? + public var indentSize: Int? + public var usesTabsForIndentation: Bool? + public var lineEnding: String = "\n" + + init( + uti: String? = nil, + tabSize: Int? = nil, + indentSize: Int? = nil, + usesTabsForIndentation: Bool? = nil, + lineEnding: String = "\n" + ) { + self.uti = uti + self.tabSize = tabSize + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.lineEnding = lineEnding + } + + public mutating func guessLineEnding(from text: String?) { + lineEnding = if let proposedEnding = text?.last { + if proposedEnding.isNewline { + String(proposedEnding) + } else { + "\n" + } + } else { + "\n" + } + } +} + +@dynamicMemberLookup +public final class Filespace { + + // MARK: Metadata + + public let fileURL: URL + public private(set) lazy var language: CodeLanguage = languageIdentifierFromFileURL(fileURL) + public var codeMetadata: FilespaceCodeMetadata = .init() + public var isTextReadable: Bool { + fileURL.pathExtension != "mlmodel" + } + + // MARK: Suggestions + + public private(set) var suggestionIndex: Int = 0 + public internal(set) var suggestions: [CodeSuggestion] = [] { + didSet { refreshUpdateTime() } + } + + public var presentingSuggestion: CodeSuggestion? { + guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil } + return suggestions[suggestionIndex] + } + + // MARK: Life Cycle + + public var isExpired: Bool { + Environment.now().timeIntervalSince(lastUpdateTime) > 60 * 3 + } + + public private(set) var lastUpdateTime: Date = Environment.now() + private var additionalProperties = FilespacePropertyValues() + let fileSaveWatcher: FileSaveWatcher + let onClose: (URL) -> Void + + @WorkspaceActor + public private(set) var version: Int = 0 + + // MARK: Methods + + deinit { + onClose(fileURL) + } + + init( + fileURL: URL, + onSave: @escaping (Filespace) -> Void, + onClose: @escaping (URL) -> Void + ) { + self.fileURL = fileURL + self.onClose = onClose + fileSaveWatcher = .init(fileURL: fileURL) + fileSaveWatcher.changeHandler = { [weak self] in + guard let self else { return } + onSave(self) + } + } + + @WorkspaceActor + public subscript( + dynamicMember dynamicMember: WritableKeyPath + ) -> K { + get { additionalProperties[keyPath: dynamicMember] } + set { additionalProperties[keyPath: dynamicMember] = newValue } + } + + @WorkspaceActor + public func reset() { + suggestions = [] + suggestionIndex = 0 + } + + @WorkspaceActor + public func updateSuggestionsWithSameSelection(_ suggestions: [CodeSuggestion]) { + self.suggestions = suggestions + suggestionIndex = suggestionIndex < suggestions.count ? suggestionIndex : 0 + } + + public func refreshUpdateTime() { + lastUpdateTime = Environment.now() + } + + @WorkspaceActor + public func setSuggestions(_ suggestions: [CodeSuggestion]) { + self.suggestions = suggestions + suggestionIndex = 0 + } + + @WorkspaceActor + public func nextSuggestion() { + suggestionIndex += 1 + if suggestionIndex >= suggestions.endIndex { + suggestionIndex = 0 + } + } + + @WorkspaceActor + public func previousSuggestion() { + suggestionIndex -= 1 + if suggestionIndex < 0 { + suggestionIndex = suggestions.endIndex - 1 + } + } + + @WorkspaceActor + public func bumpVersion() { + version += 1 + } +} + diff --git a/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift new file mode 100644 index 0000000..0683386 --- /dev/null +++ b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift @@ -0,0 +1,39 @@ +import Foundation +import Preferences + +public final class OpenedFileRecoverableStorage { + let projectRootURL: URL + let userDefault = UserDefaults.shared + let key = "OpenedFileRecoverableStorage" + + init(projectRootURL: URL) { + self.projectRootURL = projectRootURL + } + + public func openFile(fileURL: URL) { + var dict = userDefault.dictionary(forKey: key) ?? [:] + var openedFiles = Set(dict[projectRootURL.path] as? [String] ?? []) + openedFiles.insert(fileURL.path) + dict[projectRootURL.path] = Array(openedFiles) + Task { @MainActor [dict] in + userDefault.set(dict, forKey: key) + } + } + + public func closeFile(fileURL: URL) { + var dict = userDefault.dictionary(forKey: key) ?? [:] + var openedFiles = dict[projectRootURL.path] as? [String] ?? [] + openedFiles.removeAll(where: { $0 == fileURL.path }) + dict[projectRootURL.path] = openedFiles + Task { @MainActor [dict] in + userDefault.set(dict, forKey: key) + } + } + + public var openedFiles: [URL] { + let dict = userDefault.dictionary(forKey: key) ?? [:] + let openedFiles = dict[projectRootURL.path] as? [String] ?? [] + return openedFiles.map { URL(fileURLWithPath: $0) } + } +} + diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift new file mode 100644 index 0000000..117f47d --- /dev/null +++ b/Tool/Sources/Workspace/Workspace.swift @@ -0,0 +1,184 @@ +import Foundation +import Preferences +import UserDefaultsObserver +import XcodeInspector + +enum Environment { + static var now = { Date() } +} + +public protocol WorkspacePropertyKey { + associatedtype Value + static func createDefaultValue() -> Value +} + +public class WorkspacePropertyValues { + private var storage: [ObjectIdentifier: Any] = [:] + + @WorkspaceActor + public subscript(_ key: K.Type) -> K.Value { + get { + if let value = storage[ObjectIdentifier(key)] as? K.Value { + return value + } + let value = key.createDefaultValue() + storage[ObjectIdentifier(key)] = value + return value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } +} + +open class WorkspacePlugin { + public private(set) weak var workspace: Workspace? + public var projectRootURL: URL { workspace?.projectRootURL ?? URL(fileURLWithPath: "/") } + public var workspaceURL: URL { workspace?.workspaceURL ?? projectRootURL } + public var filespaces: [URL: Filespace] { workspace?.filespaces ?? [:] } + + public init(workspace: Workspace) { + self.workspace = workspace + } + + open func didOpenFilespace(_: Filespace) {} + open func didSaveFilespace(_: Filespace) {} + open func didUpdateFilespace(_: Filespace, content: String) {} + open func didCloseFilespace(_: URL) {} +} + +@dynamicMemberLookup +public final class Workspace { + public struct UnsupportedFileError: Error, LocalizedError { + public var extensionName: String + public var errorDescription: String? { + "File type \(extensionName) unsupported." + } + + public init(extensionName: String) { + self.extensionName = extensionName + } + } + + public struct CantFindWorkspaceError: Error, LocalizedError { + public var errorDescription: String? { + "Can't find workspace." + } + } + + private var additionalProperties = WorkspacePropertyValues() + public internal(set) var plugins = [ObjectIdentifier: WorkspacePlugin]() + public let workspaceURL: URL + public let projectRootURL: URL + public let openedFileRecoverableStorage: OpenedFileRecoverableStorage + public private(set) var lastLastUpdateTime = Environment.now() + public var isExpired: Bool { + Environment.now().timeIntervalSince(lastLastUpdateTime) > 60 * 60 * 1 + } + + public private(set) var filespaces = [URL: Filespace]() + + let userDefaultsObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, + UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, + ], context: nil + ) + + public subscript( + dynamicMember dynamicMember: WritableKeyPath + ) -> K { + get { additionalProperties[keyPath: dynamicMember] } + set { additionalProperties[keyPath: dynamicMember] = newValue } + } + + public func plugin(for type: P.Type) -> P? { + plugins[ObjectIdentifier(type)] as? P + } + + init(workspaceURL: URL) { + self.workspaceURL = workspaceURL + self.projectRootURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: nil + ) ?? workspaceURL + openedFileRecoverableStorage = .init(projectRootURL: projectRootURL) + let openedFiles = openedFileRecoverableStorage.openedFiles + Task { @WorkspaceActor in + for fileURL in openedFiles { + _ = createFilespaceIfNeeded(fileURL: fileURL) + } + } + } + + public func refreshUpdateTime() { + lastLastUpdateTime = Environment.now() + } + + @WorkspaceActor + public func createFilespaceIfNeeded(fileURL: URL) -> Filespace { + let existedFilespace = filespaces[fileURL] + let filespace = existedFilespace ?? .init( + fileURL: fileURL, + onSave: { [weak self] filespace in + guard let self else { return } + self.didSaveFilespace(filespace) + }, + onClose: { [weak self] url in + guard let self else { return } + self.didCloseFilespace(url) + } + ) + if filespaces[fileURL] == nil { + filespaces[fileURL] = filespace + } + if existedFilespace == nil { + didOpenFilespace(filespace) + } else { + filespace.refreshUpdateTime() + } + return filespace + } + + @WorkspaceActor + public func closeFilespace(fileURL: URL) { + filespaces[fileURL] = nil + } + + @WorkspaceActor + public func didUpdateFilespace(fileURL: URL, content: String) { + refreshUpdateTime() + guard let filespace = filespaces[fileURL] else { return } + filespace.bumpVersion() + filespace.refreshUpdateTime() + for plugin in plugins.values { + plugin.didUpdateFilespace(filespace, content: content) + } + } + + @WorkspaceActor + func didOpenFilespace(_ filespace: Filespace) { + refreshUpdateTime() + openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) + for plugin in plugins.values { + plugin.didOpenFilespace(filespace) + } + } + + @WorkspaceActor + func didCloseFilespace(_ fileURL: URL) { + for plugin in self.plugins.values { + plugin.didCloseFilespace(fileURL) + } + } + + @WorkspaceActor + func didSaveFilespace(_ filespace: Filespace) { + refreshUpdateTime() + filespace.refreshUpdateTime() + for plugin in plugins.values { + plugin.didSaveFilespace(filespace) + } + } +} + diff --git a/Tool/Sources/Workspace/WorkspacePool.swift b/Tool/Sources/Workspace/WorkspacePool.swift new file mode 100644 index 0000000..2b9a073 --- /dev/null +++ b/Tool/Sources/Workspace/WorkspacePool.swift @@ -0,0 +1,172 @@ +import Dependencies +import Foundation +import XcodeInspector + +public struct WorkspacePoolDependencyKey: DependencyKey { + public static var liveValue: WorkspacePool = .init() +} + +public extension DependencyValues { + var workspacePool: WorkspacePool { + get { self[WorkspacePoolDependencyKey.self] } + set { self[WorkspacePoolDependencyKey.self] = newValue } + } +} + +@globalActor public enum WorkspaceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +public class WorkspacePool { + public enum Error: Swift.Error, LocalizedError { + case invalidWorkspaceURL(URL) + + public var errorDescription: String? { + switch self { + case let .invalidWorkspaceURL(url): + return "Invalid workspace URL: \(url)" + } + } + } + + public internal(set) var workspaces: [URL: Workspace] = [:] + var plugins = [ObjectIdentifier: (Workspace) -> WorkspacePlugin]() + + public init( + workspaces: [URL: Workspace] = [:], + plugins: [ObjectIdentifier: (Workspace) -> WorkspacePlugin] = [:] + ) { + self.workspaces = workspaces + self.plugins = plugins + } + + public func registerPlugin(_ plugin: @escaping (Workspace) -> Plugin) { + let id = ObjectIdentifier(Plugin.self) + let erasedPlugin: (Workspace) -> WorkspacePlugin = { plugin($0) } + plugins[id] = erasedPlugin + + for workspace in workspaces.values { + addPlugin(erasedPlugin, id: id, to: workspace) + } + } + + public func unregisterPlugin(_: Plugin.Type) { + let id = ObjectIdentifier(Plugin.self) + plugins[id] = nil + + for workspace in workspaces.values { + removePlugin(id: id, from: workspace) + } + } + + public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { + for workspace in workspaces.values { + if let filespace = workspace.filespaces[fileURL] { + return filespace + } + } + return nil + } + + @WorkspaceActor + public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { + guard workspaceURL != URL(fileURLWithPath: "/") else { + throw Error.invalidWorkspaceURL(workspaceURL) + } + + if let existed = workspaces[workspaceURL] { + return existed + } + + let new = createNewWorkspace(workspaceURL: workspaceURL) + workspaces[workspaceURL] = new + return new + } + + @WorkspaceActor + public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws + -> (workspace: Workspace, filespace: Filespace) + { + // If we can get the workspace URL directly. + if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { + if let existed = workspaces[currentWorkspaceURL] { + // Reuse the existed workspace. + let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) + return (existed, filespace) + } + + let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) + workspaces[currentWorkspaceURL] = new + let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) + return (new, filespace) + } + + // If not, we try to reuse a filespace if found. + // + // Sometimes, we can't get the project root path from Xcode window, for example, when the + // quick open window in displayed. + for workspace in workspaces.values { + if let filespace = workspace.filespaces[fileURL] { + return (workspace, filespace) + } + } + + // If we can't find the workspace URL, we will try to guess it. + // Most of the time we won't enter this branch, just incase. + + if let workspaceURL = WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: nil, + documentURL: fileURL + ) { + let workspace = { + if let existed = workspaces[workspaceURL] { + return existed + } + // Reuse existed workspace if possible + for (_, workspace) in workspaces { + if fileURL.path.hasPrefix(workspace.projectRootURL.path) { + return workspace + } + } + return createNewWorkspace(workspaceURL: workspaceURL) + }() + + let filespace = workspace.createFilespaceIfNeeded(fileURL: fileURL) + workspaces[workspaceURL] = workspace + workspace.refreshUpdateTime() + return (workspace, filespace) + } + + throw Workspace.CantFindWorkspaceError() + } + + @WorkspaceActor + public func removeWorkspace(url: URL) { + workspaces[url] = nil + } +} + +extension WorkspacePool { + func addPlugin( + _ plugin: (Workspace) -> WorkspacePlugin, + id: ObjectIdentifier, + to workspace: Workspace + ) { + if workspace.plugins[id] != nil { return } + workspace.plugins[id] = plugin(workspace) + } + + func removePlugin(id: ObjectIdentifier, from workspace: Workspace) { + workspace.plugins[id] = nil + } + + func createNewWorkspace(workspaceURL: URL) -> Workspace { + let new = Workspace(workspaceURL: workspaceURL) + for (id, plugin) in plugins { + addPlugin(plugin, id: id, to: new) + } + return new + } +} + diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift new file mode 100644 index 0000000..47e1d9d --- /dev/null +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -0,0 +1,130 @@ +import Foundation +import SuggestionBasic +import Workspace +import XPCShared + +public struct FilespaceSuggestionSnapshot: Equatable { + public let linesHash: Int + public let prefixLinesHash: Int + public let suffixLinesHash: Int + public let cursorPosition: CursorPosition + public let currentLine: String + + public init(lines: [String], cursorPosition: CursorPosition) { + func safeIndex(_ index: Int) -> Int { + return max(min(index, lines.endIndex), lines.startIndex) + } + + self.linesHash = lines.hashValue + self.cursorPosition = cursorPosition + self.prefixLinesHash = lines[0..= lines.startIndex && cursorPosition.line < lines.endIndex ? lines[safeIndex(cursorPosition.line)] : "" + } + + public init(content: EditorContent) { + self.init(lines: content.lines, cursorPosition: content.cursorPosition) + } + + public func equalOrOnlyCurrentLineDiffers(comparedTo: FilespaceSuggestionSnapshot) -> Bool { + return prefixLinesHash == comparedTo.prefixLinesHash && + suffixLinesHash == comparedTo.suffixLinesHash && + cursorPosition.line == comparedTo.cursorPosition.line + } +} + +public struct FilespaceSuggestionSnapshotKey: 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 } + } +} + +public extension Filespace { + @WorkspaceActor + func resetSnapshot() { + // swiftformat:disable redundantSelf + self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue() + // swiftformat:enable all + } + + /// Validate the suggestion is still valid. + /// - Parameters: + /// - lines: lines of the file + /// - cursorPosition: cursor position + /// - Returns: `true` if the suggestion is still valid + @WorkspaceActor + func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { + guard let presentingSuggestion else { return false } + + let updatedSnapshot = FilespaceSuggestionSnapshot(lines: lines, cursorPosition: cursorPosition) + + // document state is unchanged + if updatedSnapshot == self.suggestionSourceSnapshot { + return true + } + + // other parts of the document have changed + if !self.suggestionSourceSnapshot.equalOrOnlyCurrentLineDiffers(comparedTo: updatedSnapshot) { + reset() + resetSnapshot() + return false + } + + // the suggestion does not start on the current line + if presentingSuggestion.range.start.line != cursorPosition.line || + presentingSuggestion.range.start.character != 0 { + reset() + resetSnapshot() + return false + } + + // the cursor position is invalid + if cursorPosition.line >= lines.count { + reset() + resetSnapshot() + return false + } + + let edit = LineEdit( + snapshot: self.suggestionSourceSnapshot, + suggestion: presentingSuggestion, + lines: lines, + cursor: cursorPosition + ) + let suggestionLines = presentingSuggestion.text.split(whereSeparator: \.isNewline) + let suggestionFirstLine = suggestionLines.first ?? "" + + // there is user-entered text to the right of the cursor + if edit.userEntered.count > cursorPosition.character { + reset() + resetSnapshot() + return false + } + + // the replacement range can't be adjusted + if presentingSuggestion.range.end.line != cursorPosition.line { + reset() + resetSnapshot() + return false + } + + // typing into the completion + if edit.line.count < suggestionFirstLine.count && suggestionFirstLine.hasPrefix(edit.userEntered) { + updateSuggestionsWithSameSelection(edit.updateSuggestions(suggestions)) + return true + } + + reset() + resetSnapshot() + return false + } + +} + diff --git a/Tool/Sources/WorkspaceSuggestionService/LineEdit.swift b/Tool/Sources/WorkspaceSuggestionService/LineEdit.swift new file mode 100644 index 0000000..ceea6ee --- /dev/null +++ b/Tool/Sources/WorkspaceSuggestionService/LineEdit.swift @@ -0,0 +1,139 @@ +import Foundation +import SuggestionBasic + +/// Represents an edit from a previous state of the document to the current +/// state when the modified portion of the document is constrained to the +/// current line (the line containing the cursor). +/// +/// This divides the current line into a `head` and `tail`. The `head` is +/// everything to the left of the cursor. +/// +/// The `tail` is all content to the right of the cursor which is permitted +/// when displaying a completion. That is, any content right of the cursor +/// which was present when the completion was first requested and any +/// characters which are permitted to the immediate right of the cursor for +/// middle-of-line completions (e.g. closing parens or braces). +/// +/// This also provides a `userEntered` property which contains everything to +/// the left of the cursor and any content to the right of the cursor which is +/// not permitted in a valid `tail`. When the `userEntered` portion extends to +/// the right of the cursor, it indicates an invalid middle-of-line position +/// for a completion (and any suggestions being shown must be invalidated). +/// +/// As an example, consider a file with this initial content (where `|` is the +/// cursor): +/// +/// ``` +/// let nestedTuple = (1, |) +/// ``` +/// +/// If the document is changed to (closing paren added automatically by the editor): +/// +/// ``` +/// let nestedTuple = (1, (2,|)) +/// ``` +/// +/// Here is how those properties would be set: +/// +/// ``` +/// let nestedTuple = (1, (2,|)) +/// ^ ^ = head +/// ^ ^ = userEntered +/// ^ ^ = tail +/// ``` +/// +/// An important responsibility of this type is determining how a `CodeSuggestion` +/// must be updated following the edit to remain vaild. This is handled by the +/// `updateSuggestions` method, which modifies the cursor position and selected +/// range of text to match the new document locations following the edit. +public struct LineEdit { + public let previousState: FilespaceSuggestionSnapshot + public let suggestion: CodeSuggestion + public let line: String.SubSequence + public let cursor: CursorPosition + public let headEnd: String.Index + public let tailStart: String.Index + + static let tailChars: Set = [")", "}", "]", "\"", "'", "`"] + + /// The portion of the line to the left of the cursor. + public var head: String.SubSequence { + line[.. String.Index { + return onLine.index(onLine.startIndex, offsetBy: pos.character, limitedBy: onLine.endIndex) ?? onLine.endIndex + } + + func nextTailChar() -> Character { + return newLine[newLine.index(before: tailIdx)] + } + + let oldPos = previousState.cursorPosition + let oldLine = previousState.currentLine.dropLast(1) + let oldTail = oldLine[cursorIdx(oldPos, onLine: oldLine)...] + let newPos = cursorIdx(cursor, onLine: line) + let afterCursor = line[newPos...] + + // start with the same tail present when the completion was generated (if any) + if afterCursor.hasSuffix(oldTail) { + tailIdx = line.index(line.endIndex, offsetBy: -oldTail.count) + } + + // add any whitespace or valid middle of line characters from the old tail up to the cursor + while tailIdx > newPos && (LineEdit.tailChars.contains(nextTailChar()) || nextTailChar().isWhitespace) { + tailIdx = line.index(before: tailIdx) + } + + self.headEnd = newPos + self.tailStart = tailIdx + } + + /// Returns a new set of code suggestions containing the same suggestion + /// content, but updated with new cursor position and replacement ranges to + /// match this edit. + public func updateSuggestions(_ suggestions: [CodeSuggestion]) -> [CodeSuggestion] { + return suggestions.map({ + guard $0.position == suggestion.position else { return $0 } + + // if the tail includes everything right of the cursor, keep the + // range the same distance from the end of the line + let distance = previousState.currentLine.dropLast(1).count - $0.range.end.character + let rangeEnd = if headEnd == tailStart && $0.range.end.line == cursor.line { + CursorPosition(line: cursor.line, character: line.count - distance) + } else { + // otherwise (this is not expected), use the cursor position + cursor + } + + return CodeSuggestion( + id: $0.id, + text: $0.text, + position: cursor, + range: CursorRange(start: $0.range.start, end: rangeEnd) + ) + }) + } +} + diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift new file mode 100644 index 0000000..4b7403a --- /dev/null +++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionWorkspacePlugin.swift @@ -0,0 +1,95 @@ +import BuiltinExtension +import Foundation +import Preferences +import SuggestionBasic +import SuggestionProvider +import UserDefaultsObserver +import Workspace + +public final class SuggestionServiceWorkspacePlugin: WorkspacePlugin { + public typealias SuggestionServiceFactory = () -> any SuggestionServiceProvider + let suggestionServiceFactory: SuggestionServiceFactory + + let suggestionFeatureUsabilityObserver = UserDefaultsObserver( + object: UserDefaults.shared, forKeyPaths: [ + UserDefaultPreferenceKeys().suggestionFeatureEnabledProjectList.key, + UserDefaultPreferenceKeys().disableSuggestionFeatureGlobally.key, + ], context: nil + ) + + let providerChangeObserver = UserDefaultsObserver( + object: UserDefaults.shared, + forKeyPaths: [UserDefaultPreferenceKeys().suggestionFeatureProvider.key], + context: nil + ) + + public var isRealtimeSuggestionEnabled: Bool { + UserDefaults.shared.value(for: \.realtimeSuggestionToggle) + } + + private var _suggestionService: SuggestionServiceProvider? + + public var suggestionService: SuggestionServiceProvider? { + // Check if the workspace is disabled. + let isSuggestionDisabledGlobally = UserDefaults.shared + .value(for: \.disableSuggestionFeatureGlobally) + if isSuggestionDisabledGlobally { + let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) + if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { + // If it's disable, remove the service + _suggestionService = nil + return nil + } + } + + if _suggestionService == nil { + _suggestionService = suggestionServiceFactory() + } + return _suggestionService + } + + public var isSuggestionFeatureEnabled: Bool { + let isSuggestionDisabledGlobally = UserDefaults.shared + .value(for: \.disableSuggestionFeatureGlobally) + if isSuggestionDisabledGlobally { + let enabledList = UserDefaults.shared.value(for: \.suggestionFeatureEnabledProjectList) + if !enabledList.contains(where: { path in projectRootURL.path.hasPrefix(path) }) { + return false + } + } + return true + } + + public init( + workspace: Workspace, + suggestionProviderFactory: @escaping SuggestionServiceFactory + ) { + suggestionServiceFactory = suggestionProviderFactory + super.init(workspace: workspace) + + suggestionFeatureUsabilityObserver.onChange = { [weak self] in + guard let self else { return } + _ = self.suggestionService + } + + providerChangeObserver.onChange = { [weak self] in + guard let self else { return } + self._suggestionService = nil + } + } + + func notifyAccepted(_ suggestion: CodeSuggestion) async { + await suggestionService?.notifyAccepted( + suggestion, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + } + + func notifyRejected(_ suggestions: [CodeSuggestion]) async { + await suggestionService?.notifyRejected( + suggestions, + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + } +} + diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift new file mode 100644 index 0000000..e1173ad --- /dev/null +++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift @@ -0,0 +1,171 @@ +import Foundation +import GitHubCopilotService +import SuggestionBasic +import SuggestionProvider +import Workspace +import XPCShared + +public extension Workspace { + var suggestionPlugin: SuggestionServiceWorkspacePlugin? { + plugin(for: SuggestionServiceWorkspacePlugin.self) + } + + var suggestionService: SuggestionServiceProvider? { + suggestionPlugin?.suggestionService + } + + var isSuggestionFeatureEnabled: Bool { + suggestionPlugin?.isSuggestionFeatureEnabled ?? false + } + + var gitHubCopilotPlugin: GitHubCopilotWorkspacePlugin? { + plugin(for: GitHubCopilotWorkspacePlugin.self) + } + + var gitHubCopilotService: GitHubCopilotService? { + gitHubCopilotPlugin?.gitHubCopilotService + } + + struct SuggestionFeatureDisabledError: Error, LocalizedError { + public var errorDescription: String? { + "Suggestion feature is disabled for this project." + } + } +} + +public extension Workspace { + @WorkspaceActor + @discardableResult + func generateSuggestions( + forFileAt fileURL: URL, + editor: EditorContent + ) async throws -> [CodeSuggestion] { + refreshUpdateTime() + + let filespace = 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.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( + .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: [] + ), + workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL) + ) + + filespace.setSuggestions(completions) + + return completions + } + + @WorkspaceActor + func selectNextSuggestion(forFileAt fileURL: URL) { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + filespace.suggestions.count > 1 + else { return } + filespace.nextSuggestion() + } + + @WorkspaceActor + func selectPreviousSuggestion(forFileAt fileURL: URL) { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + filespace.suggestions.count > 1 + else { return } + filespace.previousSuggestion() + } + + @WorkspaceActor + func notifySuggestionShown(fileFileAt fileURL: URL) { + if let suggestion = filespaces[fileURL]?.presentingSuggestion { + Task { + await gitHubCopilotService?.notifyShown(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 + } + + Task { + await suggestionService?.notifyRejected( + filespaces[fileURL]?.suggestions ?? [], + workspaceInfo: .init( + workspaceURL: workspaceURL, + projectURL: projectRootURL + ) + ) + } + filespaces[fileURL]?.reset() + } + + @WorkspaceActor + func acceptSuggestion(forFileAt fileURL: URL, editor: EditorContent?, suggestionLineLimit: Int? = nil) -> CodeSuggestion? { + refreshUpdateTime() + guard let filespace = filespaces[fileURL], + !filespace.suggestions.isEmpty, + filespace.suggestionIndex >= 0, + filespace.suggestionIndex < filespace.suggestions.endIndex + 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 + } + + var allSuggestions = filespace.suggestions + let suggestion = allSuggestions.remove(at: filespace.suggestionIndex) + + var length: Int? = nil + if let suggestionLineLimit { + let lines = suggestion.text.breakLines( + proposedLineEnding: filespaces[fileURL]?.codeMetadata.lineEnding + ) + length = lines.prefix(suggestionLineLimit).joined().count + } + + Task { + await gitHubCopilotService?.notifyAccepted(suggestion, acceptedLength: length) + } + + filespaces[fileURL]?.reset() + filespaces[fileURL]?.resetSnapshot() + + return suggestion + } +} + diff --git a/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift b/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift new file mode 100644 index 0000000..aaf8c05 --- /dev/null +++ b/Tool/Sources/XPCShared/CommunicationBridgeXPCServiceProtocol.swift @@ -0,0 +1,12 @@ +import Foundation + +@objc(CommunicationBridgeXPCServiceProtocol) +public protocol CommunicationBridgeXPCServiceProtocol { + func launchExtensionServiceIfNeeded(withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void) + func quit(withReply reply: @escaping () -> Void) + func updateServiceEndpoint( + endpoint: NSXPCListenerEndpoint, + withReply reply: @escaping () -> Void + ) +} + diff --git a/Tool/Sources/XPCShared/Models.swift b/Tool/Sources/XPCShared/Models.swift new file mode 100644 index 0000000..6cd6134 --- /dev/null +++ b/Tool/Sources/XPCShared/Models.swift @@ -0,0 +1,77 @@ +import Foundation +import SuggestionBasic + +public struct EditorContent: Codable { + public struct Selection: Codable { + public var start: CursorPosition + public var end: CursorPosition + + public init(start: CursorPosition, end: CursorPosition) { + self.start = start + self.end = end + } + } + + public init( + content: String, + lines: [String], + uti: String, + cursorPosition: CursorPosition, + cursorOffset: Int, + selections: [Selection], + tabSize: Int, + indentSize: Int, + usesTabsForIndentation: Bool, + suggesionLineLimit: Int? = nil + ) { + self.content = content + self.lines = lines + self.uti = uti + self.cursorPosition = cursorPosition + self.cursorOffset = cursorOffset + self.selections = selections + self.tabSize = tabSize + self.indentSize = indentSize + self.usesTabsForIndentation = usesTabsForIndentation + self.suggesionLineLimit = suggesionLineLimit + } + + public var content: String + /// Every line has a trailing newline character. + public var lines: [String] + public var uti: String + public var cursorPosition: CursorPosition + public var cursorOffset: Int + public var selections: [Selection] + public var tabSize: Int + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var suggesionLineLimit: Int? + + public func selectedCode(in selection: Selection) -> String { + return XPCShared.selectedCode(in: selection, for: lines) + } +} + +public struct UpdatedContent: Codable { + public init(content: String, newSelection: CursorRange? = nil, modifications: [Modification]) { + self.content = content + self.newSelection = newSelection + self.modifications = modifications + } + + public var content: String + public var newSelection: CursorRange? + public var modifications: [Modification] +} + +func selectedCode(in selection: EditorContent.Selection, for lines: [String]) -> String { + return EditorInformation.code( + in: lines, + inside: .init( + start: .init(line: selection.start.line, character: selection.start.character), + end: .init(line: selection.end.line, character: selection.end.character) + ), + ignoreColumns: false + ).code +} diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift new file mode 100644 index 0000000..610b6c5 --- /dev/null +++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift @@ -0,0 +1,81 @@ +import Foundation +import Logger + +public enum XPCCommunicationBridgeError: Swift.Error, LocalizedError { + case failedToCreateXPCConnection + case xpcServiceError(Error) + + public var errorDescription: String? { + switch self { + case .failedToCreateXPCConnection: + return "Failed to create XPC connection." + case let .xpcServiceError(error): + return "Connection to communication bridge error: \(error.localizedDescription)" + } + } +} + +public class XPCCommunicationBridge { + let service: XPCService + let logger: Logger + @XPCServiceActor + var serviceEndpoint: NSXPCListenerEndpoint? + + public init(logger: Logger) { + service = .init( + kind: .machService( + identifier: Bundle(for: XPCService.self) + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String + + ".CommunicationBridge" + ), + interface: NSXPCInterface(with: CommunicationBridgeXPCServiceProtocol.self), + logger: logger + ) + self.logger = logger + } + + public func setDelegate(_ delegate: XPCServiceDelegate?) { + service.delegate = delegate + } + + @discardableResult + public func launchExtensionServiceIfNeeded() async throws -> NSXPCListenerEndpoint? { + try await withXPCServiceConnected { service, continuation in + service.launchExtensionServiceIfNeeded { endpoint in + continuation.resume(endpoint) + } + } + } + + public func quit() async throws { + try await withXPCServiceConnected { service, continuation in + service.quit { + continuation.resume(()) + } + } + } + + public func updateServiceEndpoint(_ endpoint: NSXPCListenerEndpoint) async throws { + try await withXPCServiceConnected { service, continuation in + service.updateServiceEndpoint(endpoint: endpoint) { + continuation.resume(()) + } + } + } +} + +extension XPCCommunicationBridge { + @XPCServiceActor + func withXPCServiceConnected( + _ fn: @escaping (CommunicationBridgeXPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + guard let connection = service.connection + else { throw XPCCommunicationBridgeError.failedToCreateXPCConnection } + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCCommunicationBridgeError.xpcServiceError(error) + } + } +} + diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift new file mode 100644 index 0000000..00f8254 --- /dev/null +++ b/Tool/Sources/XPCShared/XPCExtensionService.swift @@ -0,0 +1,287 @@ +import Foundation +import Logger + +public enum XPCExtensionServiceError: Swift.Error, LocalizedError { + case failedToGetServiceEndpoint + case failedToCreateXPCConnection + case xpcServiceError(Error) + + public var errorDescription: String? { + switch self { + case .failedToGetServiceEndpoint: + return "Waiting for service to connect to the communication bridge." + case .failedToCreateXPCConnection: + return "Failed to create XPC connection." + case let .xpcServiceError(error): + return "Connection to extension service error: \(error.localizedDescription)" + } + } +} + +public class XPCExtensionService { + @XPCServiceActor + var service: XPCService? + @XPCServiceActor + var connection: NSXPCConnection? { service?.connection } + let logger: Logger + let bridge: XPCCommunicationBridge + + public nonisolated + init(logger: Logger) { + self.logger = logger + bridge = XPCCommunicationBridge(logger: logger) + } + + /// Launches the extension service if it's not running, returns true if the service has finished + /// launching and the communication becomes available. + @XPCServiceActor + public func launchIfNeeded() async throws -> Bool { + try await bridge.launchExtensionServiceIfNeeded() != nil + } + + public func getXPCServiceVersion() async throws -> (version: String, build: String) { + try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceVersion { version, build in + continuation.resume((version, build)) + } + } + } + + public func getXPCServiceAccessibilityPermission() async throws -> Bool { + try await withXPCServiceConnected { + service, continuation in + service.getXPCServiceAccessibilityPermission { isGranted in + continuation.resume(isGranted) + } + } + } + + public func getSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getSuggestedCode } + ) + } + + public func getNextSuggestedCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.getNextSuggestedCode } + ) + } + + public func getPreviousSuggestedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getPreviousSuggestedCode } + ) + } + + public func getSuggestionAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getSuggestionAcceptedCode } + ) + } + + public func getSuggestionRejectedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getSuggestionRejectedCode } + ) + } + + public func getRealtimeSuggestedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getRealtimeSuggestedCode } + ) + } + + public func getPromptToCodeAcceptedCode(editorContent: EditorContent) async throws + -> UpdatedContent? + { + try await suggestionRequest( + editorContent, + { $0.getPromptToCodeAcceptedCode } + ) + } + + public func toggleRealtimeSuggestion() async throws { + try await withXPCServiceConnected { + service, continuation in + service.toggleRealtimeSuggestion { 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 } + try? await withXPCServiceConnected { service, continuation in + service.prefetchRealtimeSuggestions(editorContent: data) { + continuation.resume(()) + } + } + } + + public func openChat(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.openChat } + ) + } + + public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { $0.promptToCode } + ) + } + + public func customCommand( + id: String, + editorContent: EditorContent + ) async throws -> UpdatedContent? { + try await suggestionRequest( + editorContent, + { service in { service.customCommand(id: id, editorContent: $0, withReply: $1) } } + ) + } + + public func postNotification(name: String) async throws { + try await withXPCServiceConnected { + service, continuation in + service.postNotification(name: name) { + continuation.resume(()) + } + } + } + + public func send( + requestBody: M + ) async throws -> M.ResponseBody { + try await withXPCServiceConnected { service, continuation in + do { + let requestBodyData = try JSONEncoder().encode(requestBody) + service.send(endpoint: M.endpoint, requestBody: requestBodyData) { data, error in + if let error { + continuation.reject(error) + } else { + do { + guard let data = data else { + continuation.reject(NoDataError()) + return + } + let responseBody = try JSONDecoder().decode( + M.ResponseBody.self, + from: data + ) + continuation.resume(responseBody) + } catch { + continuation.reject(error) + } + } + } + } catch { + continuation.reject(error) + } + } + } +} + +extension XPCExtensionService: XPCServiceDelegate { + public func connectionDidInterrupt() async { + Task { @XPCServiceActor in + service = nil + } + } + + public func connectionDidInvalidate() async { + Task { @XPCServiceActor in + service = nil + } + } +} + +extension XPCExtensionService { + @XPCServiceActor + private func updateEndpoint(_ endpoint: NSXPCListenerEndpoint) { + service = XPCService( + kind: .anonymous(endpoint: endpoint), + interface: NSXPCInterface(with: XPCServiceProtocol.self), + logger: logger, + delegate: self + ) + } + + @XPCServiceActor + private func withXPCServiceConnected( + _ fn: @escaping (XPCServiceProtocol, AutoFinishContinuation) -> Void + ) async throws -> T { + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } else { + guard let endpoint = try await bridge.launchExtensionServiceIfNeeded() + else { throw XPCExtensionServiceError.failedToGetServiceEndpoint } + updateEndpoint(endpoint) + + if let service, let connection = service.connection { + do { + return try await XPCShared.withXPCServiceConnected(connection: connection, fn) + } catch { + throw XPCExtensionServiceError.xpcServiceError(error) + } + } else { + throw XPCExtensionServiceError.failedToCreateXPCConnection + } + } + } + + @XPCServiceActor + private func suggestionRequest( + _ editorContent: EditorContent, + _ fn: @escaping (any XPCServiceProtocol) -> (Data, @escaping (Data?, Error?) -> Void) + -> Void + ) async throws -> UpdatedContent? { + let data = try JSONEncoder().encode(editorContent) + return try await withXPCServiceConnected { + service, continuation in + fn(service)(data) { updatedData, error in + if let error { + continuation.reject(error) + return + } + do { + if let updatedData { + let updatedContent = try JSONDecoder() + .decode(UpdatedContent.self, from: updatedData) + continuation.resume(updatedContent) + } else { + continuation.resume(nil) + } + } catch { + continuation.reject(error) + } + } + } + } +} + diff --git a/Tool/Sources/XPCShared/XPCService.swift b/Tool/Sources/XPCShared/XPCService.swift new file mode 100644 index 0000000..218f9af --- /dev/null +++ b/Tool/Sources/XPCShared/XPCService.swift @@ -0,0 +1,153 @@ +import Foundation +import Logger + +@globalActor +public enum XPCServiceActor { + public actor TheActor {} + public static let shared = TheActor() +} + +class XPCService { + enum Kind { + case machService(identifier: String) + case anonymous(endpoint: NSXPCListenerEndpoint) + } + + let kind: Kind + let interface: NSXPCInterface + let logger: Logger + weak var delegate: XPCServiceDelegate? + + @XPCServiceActor + private var isInvalidated = false + + @XPCServiceActor + private lazy var _connection: InvalidatingConnection? = buildConnection() + + @XPCServiceActor + var connection: NSXPCConnection? { + if isInvalidated { _connection = nil } + if _connection == nil { rebuildConnection() } + return _connection?.connection + } + + init( + kind: Kind, + interface: NSXPCInterface, + logger: Logger, + delegate: XPCServiceDelegate? = nil + ) { + self.kind = kind + self.interface = interface + self.logger = logger + self.delegate = delegate + } + + @XPCServiceActor + private func buildConnection() -> InvalidatingConnection { + let connection = switch kind { + case let .machService(name): + NSXPCConnection(machServiceName: name) + case let .anonymous(endpoint): + NSXPCConnection(listenerEndpoint: endpoint) + } + connection.remoteObjectInterface = interface + connection.invalidationHandler = { [weak self] in + Task { [weak self] in + self?.markAsInvalidated() + await self?.delegate?.connectionDidInvalidate() + } + } + connection.interruptionHandler = { [weak self] in + self?.logger.info("XPCService interrupted") + Task { [weak self] in + await self?.delegate?.connectionDidInterrupt() + } + } + connection.resume() + return .init(connection) + } + + @XPCServiceActor + private func markAsInvalidated() { + isInvalidated = true + } + + @XPCServiceActor + private func rebuildConnection() { + _connection = buildConnection() + } +} + +public protocol XPCServiceDelegate: AnyObject { + func connectionDidInvalidate() async + func connectionDidInterrupt() async +} + +private class InvalidatingConnection { + let connection: NSXPCConnection + init(_ connection: NSXPCConnection) { + self.connection = connection + } + + deinit { + connection.invalidationHandler = {} + connection.interruptionHandler = {} + connection.invalidate() + } +} + +struct NoDataError: Error {} + +struct AutoFinishContinuation { + var continuation: AsyncThrowingStream.Continuation + + func resume(_ value: T) { + continuation.yield(value) + continuation.finish() + } + + func reject(_ error: Error) { + if (error as NSError).code == -100 { + continuation.finish(throwing: CancellationError()) + } else { + continuation.finish(throwing: error) + } + } +} + +@XPCServiceActor +func withXPCServiceConnected( + connection: NSXPCConnection, + _ fn: @escaping (P, AutoFinishContinuation) -> Void +) async throws -> T { + let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in + let service = connection.remoteObjectProxyWithErrorHandler { + continuation.finish(throwing: $0) + } as! P + fn(service, .init(continuation: continuation)) + } + for try await result in stream { + return result + } + throw XPCExtensionServiceError.failedToCreateXPCConnection +} + +@XPCServiceActor +public func testXPCListenerEndpoint(_ endpoint: NSXPCListenerEndpoint) async -> Bool { + let connection = NSXPCConnection(listenerEndpoint: endpoint) + defer { connection.invalidate() } + let stream: AsyncThrowingStream = AsyncThrowingStream { continuation in + _ = connection.remoteObjectProxyWithErrorHandler { + continuation.finish(throwing: $0) + } + continuation.yield(()) + continuation.finish() + } + do { + try await stream.first(where: { _ in true })! + return true + } catch { + return false + } +} diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift new file mode 100644 index 0000000..0c37d8c --- /dev/null +++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift @@ -0,0 +1,155 @@ +import Foundation +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 customCommand( + id: String, + editorContent: Data, + withReply reply: @escaping (Data?, Error?) -> Void + ) + + func toggleRealtimeSuggestion(withReply reply: @escaping (Error?) -> Void) + + func prefetchRealtimeSuggestions( + editorContent: Data, + withReply reply: @escaping () -> Void + ) + + func getXPCServiceVersion(withReply reply: @escaping (String, String) -> Void) + func getXPCServiceAccessibilityPermission(withReply reply: @escaping (Bool) -> Void) + func postNotification(name: String, withReply reply: @escaping () -> Void) + func send(endpoint: String, requestBody: Data, reply: @escaping (Data?, Error?) -> Void) +} + +public struct NoResponse: Codable { + public static let none = NoResponse() +} + +public protocol ExtensionServiceRequestType: Codable { + associatedtype ResponseBody: Codable + static var endpoint: String { get } +} + +public enum ExtensionServiceRequests { + public struct OpenExtensionManager: ExtensionServiceRequestType { + public typealias ResponseBody = NoResponse + public static let endpoint = "OpenExtensionManager" + + public init() {} + } + + public struct GetExtensionSuggestionServices: ExtensionServiceRequestType { + public struct ServiceInfo: Codable { + public var bundleIdentifier: String + public var name: String + + public init(bundleIdentifier: String, name: String) { + self.bundleIdentifier = bundleIdentifier + self.name = name + } + } + + public typealias ResponseBody = [ServiceInfo] + public static let endpoint = "GetExtensionSuggestionServices" + + public init() {} + } +} + +public struct XPCRequestHandlerHitError: Error, LocalizedError { + public var errorDescription: String? { + "This is not an actual error, it just indicates a request handler was hit, and no more check is needed." + } + + public init() {} +} + +public struct XPCRequestNotHandledError: Error, LocalizedError { + public var errorDescription: String? { + "The request was not handled by the XPC server." + } + + public init() {} +} + +extension ExtensionServiceRequestType { + /// A helper method to handle requests. + static func _handle( + endpoint: String, + requestBody data: Data, + reply: @escaping (Data?, Error?) -> Void, + handler: @escaping (Request) async throws -> Response + ) throws { + guard endpoint == Self.endpoint else { + return + } + do { + let requestBody = try JSONDecoder().decode(Request.self, from: data) + Task { + do { + let responseBody = try await handler(requestBody) + let responseBodyData = try JSONEncoder().encode(responseBody) + reply(responseBodyData, nil) + } catch { + reply(nil, error) + } + } + } catch { + reply(nil, error) + } + throw XPCRequestHandlerHitError() + } + + public static func handle( + endpoint: String, + requestBody data: Data, + reply: @escaping (Data?, Error?) -> Void, + handler: @escaping (Self) async throws -> Self.ResponseBody + ) throws { + try _handle( + endpoint: endpoint, + requestBody: data, + reply: reply + ) { (request: Self) async throws -> Self.ResponseBody in + try await handler(request) + } + } +} + diff --git a/Tool/Sources/XcodeInspector/AppInstanceInspector.swift b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift new file mode 100644 index 0000000..1245d98 --- /dev/null +++ b/Tool/Sources/XcodeInspector/AppInstanceInspector.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation + +public class AppInstanceInspector: ObservableObject { + 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 isTerminated: Bool { + return runningApplication.isTerminated + } + + public var isActive: Bool { + guard !runningApplication.isTerminated else { return false } + return runningApplication.isActive + } + + public var isXcode: Bool { + guard !runningApplication.isTerminated else { return false } + return runningApplication.isXcode + } + + public var isExtensionService: Bool { + guard !runningApplication.isTerminated else { return false } + return runningApplication.isCopilotForXcodeExtensionService + } + + public func activate() -> Bool { + return runningApplication.activate() + } + + init(runningApplication: NSRunningApplication) { + self.runningApplication = runningApplication + processIdentifier = runningApplication.processIdentifier + bundleURL = runningApplication.bundleURL + bundleIdentifier = runningApplication.bundleIdentifier + } +} + diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift new file mode 100644 index 0000000..852a4de --- /dev/null +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -0,0 +1,400 @@ +import AppKit +import AsyncPassthroughSubject +import AXExtension +import AXNotificationStream +import Combine +import Foundation + +public final class XcodeAppInstanceInspector: AppInstanceInspector { + public struct AXNotification { + public var kind: AXNotificationKind + public var element: AXUIElement + } + + public enum AXNotificationKind { + case titleChanged + case applicationActivated + case applicationDeactivated + case moved + case resized + case mainWindowChanged + case focusedWindowChanged + case focusedUIElementChanged + case windowMoved + case windowResized + case windowMiniaturized + case windowDeminiaturized + case created + case uiElementDestroyed + case xcodeCompletionPanelChanged + + public init?(rawValue: String) { + switch rawValue { + case kAXTitleChangedNotification: + self = .titleChanged + case kAXApplicationActivatedNotification: + self = .applicationActivated + case kAXApplicationDeactivatedNotification: + self = .applicationDeactivated + case kAXMovedNotification: + self = .moved + case kAXResizedNotification: + self = .resized + case kAXMainWindowChangedNotification: + self = .mainWindowChanged + case kAXFocusedWindowChangedNotification: + self = .focusedWindowChanged + case kAXFocusedUIElementChangedNotification: + self = .focusedUIElementChanged + case kAXWindowMovedNotification: + self = .windowMoved + case kAXWindowResizedNotification: + self = .windowResized + case kAXWindowMiniaturizedNotification: + self = .windowMiniaturized + case kAXWindowDeminiaturizedNotification: + self = .windowDeminiaturized + case kAXCreatedNotification: + self = .created + case kAXUIElementDestroyedNotification: + self = .uiElementDestroyed + default: + return nil + } + } + } + + @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? + @Published public fileprivate(set) var documentURL: URL? = nil + @Published public fileprivate(set) var workspaceURL: URL? = nil + @Published public fileprivate(set) var projectRootURL: URL? = nil + @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() + @Published public private(set) var completionPanel: AXUIElement? + public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + updateWorkspaceInfo() + return workspaces.mapValues(\.info) + } + + 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 realtimeWorkspaceURL: URL? { + guard let window = appElement.focusedWindow, + window.identifier == "Xcode.WorkspaceWindow" + else { return nil } + + return WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) + } + + public var realtimeProjectURL: URL? { + let workspaceURL = realtimeWorkspaceURL + let documentURL = realtimeDocumentURL + return WorkspaceXcodeWindowInspector.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: documentURL + ) + } + + var _version: String? + public var version: String? { + if let _version { return _version } + guard let plistPath = runningApplication.bundleURL? + .appendingPathComponent("Contents") + .appendingPathComponent("version.plist") + .path + else { return nil } + guard let plistData = FileManager.default.contents(atPath: plistPath) else { return nil } + var format = PropertyListSerialization.PropertyListFormat.xml + guard let plistDict = try? PropertyListSerialization.propertyList( + from: plistData, + options: .mutableContainersAndLeaves, + format: &format + ) as? [String: AnyObject] else { return nil } + let result = plistDict["CFBundleShortVersionString"] as? String + _version = result + return result + } + + private var longRunningTasks = Set>() + private var focusedWindowObservations = Set() + + deinit { + axNotifications.finish() + for task in longRunningTasks { task.cancel() } + } + + override init(runningApplication: NSRunningApplication) { + super.init(runningApplication: runningApplication) + + Task { @XcodeInspectorActor in + observeFocusedWindow() + observeAXNotifications() + + try await Task.sleep(nanoseconds: 3_000_000_000) + // Sometimes the focused window may not be ready on app launch. + if !(focusedWindow is WorkspaceXcodeWindowInspector) { + observeFocusedWindow() + } + } + } + + @XcodeInspectorActor + func refresh() { + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + focusedWindow.refresh() + } else { + observeFocusedWindow() + } + } + + @XcodeInspectorActor + private func observeFocusedWindow() { + if let window = appElement.focusedWindow { + if window.identifier == "Xcode.WorkspaceWindow" { + let window = WorkspaceXcodeWindowInspector( + app: runningApplication, + uiElement: window, + axNotifications: axNotifications + ) + + focusedWindowObservations.forEach { $0.cancel() } + focusedWindowObservations.removeAll() + + Task { @MainActor in + focusedWindow = window + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL + } + + window.$documentURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.documentURL = url + }.store(in: &focusedWindowObservations) + window.$workspaceURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.workspaceURL = url + }.store(in: &focusedWindowObservations) + window.$projectRootURL + .filter { $0 != .init(fileURLWithPath: "/") } + .receive(on: DispatchQueue.main) + .sink { [weak self] url in + self?.projectRootURL = url + }.store(in: &focusedWindowObservations) + + } else { + let window = XcodeWindowInspector(uiElement: window) + Task { @MainActor in + focusedWindow = window + } + } + } else { + Task { @MainActor in + focusedWindow = nil + } + } + } + + @XcodeInspectorActor + func observeAXNotifications() { + longRunningTasks.forEach { $0.cancel() } + longRunningTasks = [] + + let axNotificationStream = AXNotificationStream( + app: runningApplication, + notificationNames: + kAXTitleChangedNotification, + kAXApplicationActivatedNotification, + kAXApplicationDeactivatedNotification, + kAXMovedNotification, + kAXResizedNotification, + kAXMainWindowChangedNotification, + kAXFocusedWindowChangedNotification, + kAXFocusedUIElementChangedNotification, + kAXWindowMovedNotification, + kAXWindowResizedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + kAXCreatedNotification, + kAXUIElementDestroyedNotification + ) + + let observeAXNotificationTask = Task { @XcodeInspectorActor [weak self] in + var updateWorkspaceInfoTask: Task? + + for await notification in axNotificationStream { + guard let self else { return } + try Task.checkCancellation() + await Task.yield() + + guard let event = AXNotificationKind(rawValue: notification.name) else { + continue + } + + self.axNotifications.send(.init(kind: event, element: notification.element)) + + if event == .focusedWindowChanged { + observeFocusedWindow() + } + + if event == .focusedUIElementChanged || event == .applicationDeactivated { + updateWorkspaceInfoTask?.cancel() + updateWorkspaceInfoTask = Task { [weak self] in + guard let self else { return } + try await Task.sleep(nanoseconds: 2_000_000_000) + try Task.checkCancellation() + self.updateWorkspaceInfo() + } + } + + if event == .created || event == .uiElementDestroyed { + let isCompletionPanel = { + notification.element.identifier == "_XC_COMPLETION_TABLE_" + || notification.element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil + } + + switch event { + case .created: + if isCompletionPanel() { + await MainActor.run { + self.completionPanel = notification.element + self.completionPanel?.setMessagingTimeout(1) + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } + } + case .uiElementDestroyed: + if isCompletionPanel() { + await MainActor.run { + self.completionPanel = nil + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } + } + default: continue + } + } + } + } + + longRunningTasks.insert(observeAXNotificationTask) + + updateWorkspaceInfo() + } +} + +// MARK: - Workspace Info + +extension XcodeAppInstanceInspector { + public enum WorkspaceIdentifier: Hashable { + case url(URL) + case unknown + } + + public class Workspace { + public let element: AXUIElement + public var info: WorkspaceInfo + + /// When a window is closed, all it's properties will be set to nil. + /// Since we can't get notification for window closing, + /// we will use it to check if the window is closed. + var isValid: Bool { + element.parent != nil + } + + init(element: AXUIElement) { + self.element = element + info = .init(tabs: []) + } + } + + public struct WorkspaceInfo { + public let tabs: Set + + public func combined(with info: WorkspaceInfo) -> WorkspaceInfo { + return .init(tabs: tabs.union(info.tabs)) + } + } + + func updateWorkspaceInfo() { + let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) + let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) + Task { @MainActor in + self.workspaces = workspaces + } + } + + /// Use the project path as the workspace identifier. + static func workspaceIdentifier(_ window: AXUIElement) -> WorkspaceIdentifier { + if let url = WorkspaceXcodeWindowInspector.extractWorkspaceURL(windowElement: window) { + return WorkspaceIdentifier.url(url) + } + return WorkspaceIdentifier.unknown + } + + /// With Accessibility API, we can ONLY get the information of visible windows. + static func fetchVisibleWorkspaces( + _ app: NSRunningApplication + ) -> [WorkspaceIdentifier: Workspace] { + let app = AXUIElementCreateApplication(app.processIdentifier) + let windows = app.windows.filter { $0.identifier == "Xcode.WorkspaceWindow" } + + var dict = [WorkspaceIdentifier: Workspace]() + + for window in windows { + let workspaceIdentifier = workspaceIdentifier(window) + + let tabs = { + guard let editArea = window.firstChild(where: { $0.description == "editor area" }) + else { return Set() } + var allTabs = Set() + let tabBars = editArea.children { $0.description == "tab bar" } + for tabBar in tabBars { + let tabs = tabBar.children { $0.roleDescription == "tab" } + for tab in tabs { + allTabs.insert(tab.title) + } + } + return allTabs + }() + + let workspace = Workspace(element: window) + workspace.info = .init(tabs: tabs) + dict[workspaceIdentifier] = workspace + } + return dict + } + + static func updateWorkspace( + _ old: [WorkspaceIdentifier: Workspace], + with new: [WorkspaceIdentifier: Workspace] + ) -> [WorkspaceIdentifier: Workspace] { + var updated = old.filter { $0.value.isValid } // remove closed windows. + for (identifier, workspace) in new { + if let existed = updated[identifier] { + existed.info = workspace.info + } else { + updated[identifier] = workspace + } + } + return updated + } +} + diff --git a/Tool/Sources/XcodeInspector/Helpers.swift b/Tool/Sources/XcodeInspector/Helpers.swift new file mode 100644 index 0000000..eab2b00 --- /dev/null +++ b/Tool/Sources/XcodeInspector/Helpers.swift @@ -0,0 +1,18 @@ +import AppKit +import Foundation + +public extension NSRunningApplication { + var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" } + var isCopilotForXcodeExtensionService: Bool { + bundleIdentifier == Bundle.main.bundleIdentifier + } +} + +public extension FileManager { + func fileIsDirectory(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + let exists = fileExists(atPath: path, isDirectory: &isDirectory) + return isDirectory.boolValue && exists + } +} + diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift new file mode 100644 index 0000000..09c7da0 --- /dev/null +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -0,0 +1,281 @@ +import AppKit +import AsyncPassthroughSubject +import AXNotificationStream +import Foundation +import Logger +import SuggestionBasic + +/// Representing a source editor inside Xcode. +public class SourceEditor { + public typealias Content = EditorInformation.SourceEditorContent + + public struct AXNotification: Hashable { + public var kind: AXNotificationKind + public var element: AXUIElement + + public func hash(into hasher: inout Hasher) { + kind.hash(into: &hasher) + } + } + + public enum AXNotificationKind: Hashable, Equatable { + case selectedTextChanged + case valueChanged + case scrollPositionChanged + case evaluatedContentChanged + } + + let runningApplication: NSRunningApplication + public let element: AXUIElement + var observeAXNotificationsTask: Task? + public let axNotifications = AsyncPassthroughSubject() + + /// To prevent expensive calculations in ``getContent()``. + private let cache = Cache() + + 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, + lines: lines, + selections: selections, + cursorPosition: selections.first?.start ?? .outOfScope, + cursorOffset: selectionRange?.lowerBound ?? 0, + lineAnnotations: lineAnnotations + ) + } + + /// Get the content of the source editor. + /// + /// - note: This method is expensive. It needs to convert index based ranges to line based + /// ranges. + public func getContent() -> Content { + let content = element.value + let selectionRange = element.selectedTextRange + 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)) + + return .init( + content: content, + lines: lines, + selections: selections, + cursorPosition: selections.first?.start ?? .outOfScope, + cursorOffset: selectionRange?.lowerBound ?? 0, + lineAnnotations: lineAnnotations + ) + } + + public init(runningApplication: NSRunningApplication, element: AXUIElement) { + self.runningApplication = runningApplication + self.element = element + element.setMessagingTimeout(2) + observeAXNotifications() + } + + private func observeAXNotifications() { + observeAXNotificationsTask?.cancel() + observeAXNotificationsTask = Task { @XcodeInspectorActor [weak self] in + guard let self else { return } + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + guard let self else { return } + let editorNotifications = AXNotificationStream( + app: runningApplication, + element: element, + notificationNames: + kAXSelectedTextChangedNotification, + kAXValueChangedNotification + ) + + group.addTask { [weak self] in + for await notification in editorNotifications { + try Task.checkCancellation() + await Task.yield() + guard let self else { return } + if let kind: AXNotificationKind = { + switch notification.name { + case kAXSelectedTextChangedNotification: return .selectedTextChanged + case kAXValueChangedNotification: return .valueChanged + default: return nil + } + }() { + self.axNotifications.send(.init( + kind: kind, + element: notification.element + )) + } + } + } + + if let scrollView = element.parent, let scrollBar = scrollView.verticalScrollBar { + let scrollViewNotifications = AXNotificationStream( + app: runningApplication, + element: scrollBar, + notificationNames: kAXValueChangedNotification + ) + + group.addTask { [weak self] in + for await notification in scrollViewNotifications { + try Task.checkCancellation() + await Task.yield() + guard let self else { return } + self.axNotifications.send(.init( + kind: .scrollPositionChanged, + element: notification.element + )) + } + } + } + + try? await group.waitForAll() + } + } + } +} + +extension SourceEditor { + final class Cache { + static let queue = DispatchQueue(label: "SourceEditor.Cache") + + private var sourceContent: String? + private var cachedLines = [String]() + private var sourceSelectedTextRange: ClosedRange? + private var cachedSelections = [CursorRange]() + + init( + sourceContent: String? = nil, + cachedLines: [String] = [String](), + sourceSelectedTextRange: ClosedRange? = nil, + cachedSelections: [CursorRange] = [CursorRange]() + ) { + self.sourceContent = sourceContent + self.cachedLines = cachedLines + self.sourceSelectedTextRange = sourceSelectedTextRange + self.cachedSelections = cachedSelections + } + + func get(content: String, selectedTextRange: ClosedRange?) -> ( + lines: [String], + selections: [CursorRange] + ) { + Self.queue.sync { + let contentMatch = content == sourceContent + let selectedRangeMatch = selectedTextRange == sourceSelectedTextRange + let lines: [String] = { + if contentMatch { + return cachedLines + } + return content.breakLines(appendLineBreakToLastLine: false) + }() + let selections: [CursorRange] = { + if contentMatch, selectedRangeMatch { + return cachedSelections + } + if let selectedTextRange { + return [SourceEditor.convertRangeToCursorRange( + selectedTextRange, + in: lines + )] + } + return [] + }() + + sourceContent = content + cachedLines = lines + sourceSelectedTextRange = selectedTextRange + cachedSelections = selections + + return (lines, selections) + } + } + + func latest() -> (content: String, lines: [String], selections: [CursorRange]) { + Self.queue.sync { + (sourceContent ?? "", cachedLines, cachedSelections) + } + } + } +} + +// MARK: - Helpers + +public extension SourceEditor { + static func convertCursorRangeToRange( + _ cursorRange: CursorRange, + in lines: [String] + ) -> CFRange { + var countS = 0 + var countE = 0 + var range = CFRange(location: 0, length: 0) + for (i, line) in lines.enumerated() { + if i == cursorRange.start.line { + countS = countS + cursorRange.start.character + range.location = countS + } + if i == cursorRange.end.line { + countE = countE + cursorRange.end.character + range.length = max(countE - range.location, 0) + break + } + countS += line.utf16.count + countE += line.utf16.count + } + return range + } + + static func convertCursorRangeToRange( + _ cursorRange: CursorRange, + in content: String + ) -> CFRange { + let lines = content.breakLines(appendLineBreakToLastLine: false) + return convertCursorRangeToRange(cursorRange, in: lines) + } + + static func convertRangeToCursorRange( + _ range: ClosedRange, + in lines: [String] + ) -> CursorRange { + guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } + var countS = 0 + var countE = 0 + var cursorRange = CursorRange(start: .zero, end: .outOfScope) + for (i, line) in lines.enumerated() { + if countS <= range.lowerBound, + range.lowerBound < countS + line.utf16.count + { + cursorRange.start = .init(line: i, character: range.lowerBound - countS) + } + if countE <= range.upperBound, + range.upperBound < countE + line.utf16.count + { + cursorRange.end = .init(line: i, character: range.upperBound - countE) + break + } + countS += line.utf16.count + countE += line.utf16.count + } + if cursorRange.end == .outOfScope { + cursorRange.end = .init( + line: lines.endIndex - 1, + character: lines.last?.utf16.count ?? 0 + ) + } + return cursorRange + } + + static func convertRangeToCursorRange( + _ range: ClosedRange, + in content: String + ) -> CursorRange { + let lines = content.breakLines(appendLineBreakToLastLine: false) + return convertRangeToCursorRange(range, in: lines) + } +} + diff --git a/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift new file mode 100644 index 0000000..e6ea06e --- /dev/null +++ b/Tool/Sources/XcodeInspector/XcodeInspector+TriggerCommand.swift @@ -0,0 +1,173 @@ +import AppKit +import AXExtension +import Foundation +import Logger + +public extension XcodeAppInstanceInspector { + func triggerCopilotCommand(name: String, activateXcode: Bool = true) async throws { + let bundleName = Bundle.main + .object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String + try await triggerMenuItem(path: ["Editor", bundleName, name], activateApp: activateXcode) + } +} + +public extension AppInstanceInspector { + struct CantRunCommand: Error, LocalizedError { + let path: String + let reason: String + public var errorDescription: String? { + "Can't run command \(path): \(reason)" + } + } + + @MainActor + func triggerMenuItem(path: [String], activateApp: Bool) async throws { + let sourcePath = path.joined(separator: "/") + func cantRunCommand(_ reason: String) -> CantRunCommand { + return CantRunCommand(path: sourcePath, reason: reason) + } + + guard path.count >= 2 else { throw cantRunCommand("Path too short.") } + + if activateApp { + if !runningApplication.activate() { + Logger.service.error(""" + Trigger menu item \(sourcePath): \ + Xcode not activated. + """) + } + } else { + if !runningApplication.isActive { + Logger.service.error(""" + Trigger menu item \(sourcePath): \ + Xcode not activated. + """) + } + } + + await Task.yield() + + if UserDefaults.shared.value(for: \.triggerActionWithAccessibilityAPI) { + let app = AXUIElementCreateApplication(runningApplication.processIdentifier) + + guard let menuBar = app.menuBar else { + Logger.service.error(""" + Trigger menu item \(sourcePath) failed: \ + Menu not found. + """) + throw cantRunCommand("Menu not found.") + } + var path = path + var currentMenu = menuBar + while !path.isEmpty { + let item = path.removeFirst() + + if path.isEmpty, let button = currentMenu.child(title: item, role: "AXMenuItem") { + let error = AXUIElementPerformAction(button, kAXPressAction as CFString) + if error != AXError.success { + Logger.service.error(""" + Trigger menu item \(sourcePath) failed: \ + \(error.localizedDescription) + """) + throw cantRunCommand(error.localizedDescription) + } else { + #if DEBUG + Logger.service.info(""" + Trigger menu item \(sourcePath) succeeded. + """) + #endif + return + } + } else if let menu = currentMenu.child(title: item) { + #if DEBUG + Logger.service.info(""" + Trigger menu item \(sourcePath): Move to \(item). + """) + #endif + currentMenu = menu + } else { + Logger.service.error(""" + Trigger menu item \(sourcePath) failed: \ + \(item) is not found. + """) + throw cantRunCommand("\(item) is not found.") + } + } + } else { + let clickTask = { + var path = path + let button = path.removeLast() + let menuBarItem = path.removeFirst() + let list = path + .reversed() + .map { "menu 1 of menu item \"\($0)\"" } + .joined(separator: " of ") + return """ + click menu item "\(button)" of \(list) \ + of menu bar item "\(menuBarItem)" \ + of menu bar 1 + """ + }() + /// check if menu is open, if not, click the menu item. + let appleScript = """ + tell application "System Events" + set theprocs to every process whose unix id is \ + \(runningApplication.processIdentifier) + repeat with proc in theprocs + tell proc + repeat with theMenu in menus of menu bar 1 + set theValue to value of attribute "AXVisibleChildren" of theMenu + if theValue is not {} then + return + end if + end repeat + \(clickTask) + end tell + end repeat + end tell + """ + + do { + try await runAppleScript(appleScript) + } catch { + Logger.service.error(""" + Trigger menu item \(path.joined(separator: "/")) failed: \ + \(error.localizedDescription) + """) + throw cantRunCommand(error.localizedDescription) + } + } + } +} + +@discardableResult +func runAppleScript(_ appleScript: String) async throws -> String { + let task = Process() + task.launchPath = "/usr/bin/osascript" + task.arguments = ["-e", appleScript] + let outpipe = Pipe() + task.standardOutput = outpipe + task.standardError = Pipe() + + return try await withUnsafeThrowingContinuation { continuation in + do { + task.terminationHandler = { _ in + do { + if let data = try outpipe.fileHandleForReading.readToEnd(), + let content = String(data: data, encoding: .utf8) + { + continuation.resume(returning: content) + return + } + continuation.resume(returning: "") + } catch { + continuation.resume(throwing: error) + } + } + try task.run() + } catch { + continuation.resume(throwing: error) + } + } +} + diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift new file mode 100644 index 0000000..51defde --- /dev/null +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -0,0 +1,404 @@ +import AppKit +import AsyncAlgorithms +import AXExtension +import Combine +import Foundation +import Logger +import Preferences +import SuggestionBasic +import Toast + +public extension Notification.Name { + static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") +} + +@globalActor +public enum XcodeInspectorActor: GlobalActor { + public actor Actor {} + public static let shared = Actor() +} + +#warning("TODO: Consider rewriting it with Swift Observation") +public final class XcodeInspector: ObservableObject { + public static let shared = XcodeInspector() + + @XcodeInspectorActor + @dynamicMemberLookup + public class Safe { + var inspector: XcodeInspector { .shared } + nonisolated init() {} + public subscript(dynamicMember member: KeyPath) -> T { + inspector[keyPath: member] + } + } + + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + private var cancellable = Set() + private var activeXcodeObservations = Set>() + private var appChangeObservations = Set>() + private var activeXcodeCancellable = Set() + + #warning("TODO: Find a good way to make XcodeInspector thread safe!") + public var safe = Safe() + + @Published public fileprivate(set) var activeApplication: AppInstanceInspector? + @Published public fileprivate(set) var previousActiveApplication: AppInstanceInspector? + @Published public fileprivate(set) var activeXcode: XcodeAppInstanceInspector? + @Published public fileprivate(set) var latestActiveXcode: XcodeAppInstanceInspector? + @Published public fileprivate(set) var xcodes: [XcodeAppInstanceInspector] = [] + @Published public fileprivate(set) var activeProjectRootURL: URL? = nil + @Published public fileprivate(set) var activeDocumentURL: URL? = nil + @Published public fileprivate(set) var activeWorkspaceURL: URL? = nil + @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? + @Published public fileprivate(set) var focusedEditor: SourceEditor? + @Published public fileprivate(set) var focusedElement: AXUIElement? + @Published public fileprivate(set) var completionPanel: AXUIElement? + + /// Get the content of the source editor. + /// + /// - note: This method is expensive. It needs to convert index based ranges to line based + /// ranges. + @XcodeInspectorActor + public func getFocusedEditorContent() async -> EditorInformation? { + guard let documentURL = realtimeActiveDocumentURL, + let workspaceURL = realtimeActiveWorkspaceURL, + let projectURL = activeProjectRootURL + else { return nil } + + let editorContent = focusedEditor?.getContent() + let language = languageIdentifierFromFileURL(documentURL) + let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") + + if let editorContent, let range = editorContent.selections.first { + let (selectedContent, selectedLines) = EditorInformation.code( + in: editorContent.lines, + inside: range + ) + return .init( + editorContent: editorContent, + selectedContent: selectedContent, + selectedLines: selectedLines, + documentURL: documentURL, + workspaceURL: workspaceURL, + projectRootURL: projectURL, + relativePath: relativePath, + language: language + ) + } + + return .init( + editorContent: editorContent, + selectedContent: "", + selectedLines: [], + documentURL: documentURL, + workspaceURL: workspaceURL, + projectRootURL: projectURL, + relativePath: relativePath, + language: language + ) + } + + public var realtimeActiveDocumentURL: URL? { + latestActiveXcode?.realtimeDocumentURL ?? activeDocumentURL + } + + public var realtimeActiveWorkspaceURL: URL? { + latestActiveXcode?.realtimeWorkspaceURL ?? activeWorkspaceURL + } + + public var realtimeActiveProjectURL: URL? { + latestActiveXcode?.realtimeProjectURL ?? activeProjectRootURL + } + + init() { + AXUIElement.setGlobalMessagingTimeout(3) + Task { @XcodeInspectorActor in + restart() + } + } + + @XcodeInspectorActor + public func restart(cleanUp: Bool = false) { + if cleanUp { + activeXcodeObservations.forEach { $0.cancel() } + activeXcodeObservations.removeAll() + activeXcodeCancellable.forEach { $0.cancel() } + activeXcodeCancellable.removeAll() + activeXcode = nil + latestActiveXcode = nil + activeApplication = nil + activeProjectRootURL = nil + activeDocumentURL = nil + activeWorkspaceURL = nil + focusedWindow = nil + focusedEditor = nil + focusedElement = nil + completionPanel = nil + } + + let runningApplications = NSWorkspace.shared.runningApplications + xcodes = runningApplications + .filter { $0.isXcode } + .map(XcodeAppInstanceInspector.init(runningApplication:)) + let activeXcode = xcodes.first(where: \.isActive) + latestActiveXcode = activeXcode ?? xcodes.first + activeApplication = activeXcode ?? runningApplications + .first(where: \.isActive) + .map(AppInstanceInspector.init(runningApplication:)) + + appChangeObservations.forEach { $0.cancel() } + appChangeObservations.removeAll() + + let appChangeTask = Task(priority: .utility) { [weak self] in + guard let self else { return } + if let activeXcode { + setActiveXcode(activeXcode) + } + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in // Did activate app + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didActivateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + if app.isXcode { + if let existed = xcodes.first(where: { + $0.processIdentifier == app.processIdentifier && !$0.isTerminated + }) { + Task { @XcodeInspectorActor in + self.setActiveXcode(existed) + } + } else { + let new = XcodeAppInstanceInspector(runningApplication: app) + Task { @XcodeInspectorActor in + self.xcodes.append(new) + self.setActiveXcode(new) + } + } + } else { + let appInspector = AppInstanceInspector(runningApplication: app) + Task { @XcodeInspectorActor in + self.previousActiveApplication = self.activeApplication + self.activeApplication = appInspector + } + } + } + } + + group.addTask { [weak self] in // Did terminate app + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: NSWorkspace.didTerminateApplicationNotification) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + else { continue } + if app.isXcode { + let processIdentifier = app.processIdentifier + Task { @XcodeInspectorActor in + self.xcodes.removeAll { + $0.processIdentifier == processIdentifier || $0.isTerminated + } + if self.latestActiveXcode?.runningApplication + .processIdentifier == processIdentifier + { + self.latestActiveXcode = nil + } + + if let activeXcode = self.xcodes.first(where: \.isActive) { + self.setActiveXcode(activeXcode) + } + } + } + } + } + + if UserDefaults.shared + .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + { + group.addTask { [weak self] in + while true { + guard let self else { return } + if UserDefaults.shared.value( + for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + ) { + return + } + + try await Task.sleep(nanoseconds: 10_000_000_000) + Task { @XcodeInspectorActor in + self.checkForAccessibilityMalfunction("Timer") + } + } + } + } + + group.addTask { [weak self] in // malfunctioning + let sequence = NotificationCenter.default + .notifications(named: .accessibilityAPIMalfunctioning) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + await self + .recoverFromAccessibilityMalfunctioning(notification.object as? String) + } + } + } + } + + appChangeObservations.insert(appChangeTask) + } + + public func reactivateObservationsToXcode() { + Task { @XcodeInspectorActor in + if let activeXcode { + setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() + } + } + } + + @XcodeInspectorActor + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + previousActiveApplication = activeApplication + activeApplication = xcode + xcode.refresh() + for task in activeXcodeObservations { task.cancel() } + for cancellable in activeXcodeCancellable { cancellable.cancel() } + activeXcodeObservations.removeAll() + activeXcodeCancellable.removeAll() + + activeXcode = xcode + latestActiveXcode = xcode + activeDocumentURL = xcode.documentURL + focusedWindow = xcode.focusedWindow + completionPanel = xcode.completionPanel + activeProjectRootURL = xcode.projectRootURL + activeWorkspaceURL = xcode.workspaceURL + focusedWindow = xcode.focusedWindow + + let setFocusedElement = { @XcodeInspectorActor [weak self] in + guard let self else { return } + focusedElement = xcode.appElement.focusedElement + if let editorElement = focusedElement, editorElement.isSourceEditor { + focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + } else if let element = focusedElement, + let editorElement = element.firstParent(where: \.isSourceEditor) + { + focusedEditor = .init( + runningApplication: xcode.runningApplication, + element: editorElement + ) + } else { + focusedEditor = nil + } + } + + setFocusedElement() + let focusedElementChanged = Task { @XcodeInspectorActor in + for await notification in await xcode.axNotifications.notifications() { + if notification.kind == .focusedUIElementChanged { + try Task.checkCancellation() + setFocusedElement() + } + } + } + + activeXcodeObservations.insert(focusedElementChanged) + + if UserDefaults.shared + .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + { + let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in + if #available(macOS 13.0, *) { + let notifications = await xcode.axNotifications.notifications().filter { + $0.kind == .uiElementDestroyed + }.debounce(for: .milliseconds(1000)) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + self.checkForAccessibilityMalfunction("Element Destroyed") + } + } + } + + activeXcodeObservations.insert(malfunctionCheck) + + checkForAccessibilityMalfunction("Reactivate Xcode") + } + + xcode.$completionPanel.sink { [weak self] element in + Task { @XcodeInspectorActor in self?.completionPanel = element } + }.store(in: &activeXcodeCancellable) + + xcode.$documentURL.sink { [weak self] url in + Task { @XcodeInspectorActor in self?.activeDocumentURL = url } + }.store(in: &activeXcodeCancellable) + + xcode.$workspaceURL.sink { [weak self] url in + Task { @XcodeInspectorActor in self?.activeWorkspaceURL = url } + }.store(in: &activeXcodeCancellable) + + xcode.$projectRootURL.sink { [weak self] url in + Task { @XcodeInspectorActor in self?.activeProjectRootURL = url } + }.store(in: &activeXcodeCancellable) + + xcode.$focusedWindow.sink { [weak self] window in + Task { @XcodeInspectorActor in self?.focusedWindow = window } + }.store(in: &activeXcodeCancellable) + } + + private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() + + @XcodeInspectorActor + private func checkForAccessibilityMalfunction(_ source: String) { + guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 + else { return } + + if let editor = focusedEditor, !editor.element.isSourceEditor { + NotificationCenter.default.post( + name: .accessibilityAPIMalfunctioning, + object: "Source Editor Element Corrupted: \(source)" + ) + } else if let element = activeXcode?.appElement.focusedElement { + if element.description != focusedElement?.description || + element.role != focusedElement?.role + { + NotificationCenter.default.post( + name: .accessibilityAPIMalfunctioning, + object: "Element Inconsistency: \(source)" + ) + } + } + } + + @XcodeInspectorActor + private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + let message = """ + Accessibility API malfunction detected: \ + \(source ?? ""). + Resetting active Xcode. + """ + + if UserDefaults.shared.value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) { + toast.toast(content: message, type: .warning) + } else { + Logger.service.info(message) + } + if let activeXcode { + lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() + setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() + } + } +} + diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift new file mode 100644 index 0000000..d250682 --- /dev/null +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -0,0 +1,155 @@ +import AppKit +import AsyncPassthroughSubject +import AXExtension +import Combine +import Foundation +import Logger + +public class XcodeWindowInspector: ObservableObject { + public let uiElement: AXUIElement + + init(uiElement: AXUIElement) { + self.uiElement = uiElement + uiElement.setMessagingTimeout(2) + } +} + +public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { + let app: NSRunningApplication + @Published public internal(set) var documentURL: URL = .init(fileURLWithPath: "/") + @Published public internal(set) var workspaceURL: URL = .init(fileURLWithPath: "/") + @Published public internal(set) var projectRootURL: URL = .init(fileURLWithPath: "/") + private var focusedElementChangedTask: Task? + + public func refresh() { + Task { @XcodeInspectorActor in updateURLs() } + } + + public init( + app: NSRunningApplication, + uiElement: AXUIElement, + axNotifications: AsyncPassthroughSubject + ) { + self.app = app + super.init(uiElement: uiElement) + + focusedElementChangedTask = Task { [weak self, axNotifications] in + await self?.updateURLs() + + await withThrowingTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in + // prevent that documentURL may not be available yet + try await Task.sleep(nanoseconds: 500_000_000) + if self?.documentURL == .init(fileURLWithPath: "/") { + await self?.updateURLs() + } + } + + group.addTask { [weak self] in + for await notification in await axNotifications.notifications() { + guard notification.kind == .focusedUIElementChanged + || notification.kind == .titleChanged + else { continue } + guard let self else { return } + try Task.checkCancellation() + await Task.yield() + await self.updateURLs() + } + } + } + } + } + + @XcodeInspectorActor + func updateURLs() { + let documentURL = Self.extractDocumentURL(windowElement: uiElement) + if let documentURL { + Task { @MainActor in + self.documentURL = documentURL + } + } + let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) + if let workspaceURL { + Task { @MainActor in + self.workspaceURL = workspaceURL + } + } + let projectURL = Self.extractProjectURL( + workspaceURL: workspaceURL, + documentURL: documentURL + ) + if let projectURL { + Task { @MainActor in + self.projectRootURL = projectURL + } + } + } + + static func extractDocumentURL( + windowElement: AXUIElement + ) -> URL? { + // fetch file path of the frontmost window of Xcode through Accessibility API. + let path = windowElement.document + if let path = path?.removingPercentEncoding { + let url = URL( + fileURLWithPath: path + .replacingOccurrences(of: "file://", with: "") + ) + return adjustFileURL(url) + } + return nil + } + + static func extractWorkspaceURL( + windowElement: AXUIElement + ) -> URL? { + for child in windowElement.children { + if child.description.starts(with: "/"), child.description.count > 1 { + let path = child.description + let trimmedNewLine = path.trimmingCharacters(in: .newlines) + let url = URL(fileURLWithPath: trimmedNewLine) + return url + } + } + return nil + } + + public static func extractProjectURL( + workspaceURL: URL?, + documentURL: URL? + ) -> URL? { + guard var currentURL = workspaceURL ?? documentURL else { return nil } + var firstDirectoryURL: URL? + var lastGitDirectoryURL: URL? + while currentURL.pathComponents.count > 1 { + defer { currentURL.deleteLastPathComponent() } + guard FileManager.default.fileIsDirectory(atPath: currentURL.path) else { continue } + guard currentURL.pathExtension != "xcodeproj" else { continue } + guard currentURL.pathExtension != "xcworkspace" else { continue } + guard currentURL.pathExtension != "playground" else { continue } + if firstDirectoryURL == nil { firstDirectoryURL = currentURL } + let gitURL = currentURL.appendingPathComponent(".git") + if FileManager.default.fileIsDirectory(atPath: gitURL.path) { + lastGitDirectoryURL = currentURL + } else if let text = try? String(contentsOf: gitURL) { + if !text.hasPrefix("gitdir: ../"), // it's not a sub module + text.range(of: "/.git/worktrees/") != nil // it's a git worktree + { + lastGitDirectoryURL = currentURL + } + } + } + + return lastGitDirectoryURL ?? firstDirectoryURL ?? workspaceURL + } + + static func adjustFileURL(_ url: URL) -> URL { + if url.pathExtension == "playground", + FileManager.default.fileIsDirectory(atPath: url.path) + { + return url.appendingPathComponent("Contents.swift") + } + return url + } +} + diff --git a/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift b/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift new file mode 100644 index 0000000..cb70853 --- /dev/null +++ b/Tool/Tests/ASTParserTests/CursorDeepFirstSearchTests.swift @@ -0,0 +1,91 @@ +import Foundation +import XCTest + +@testable import ASTParser + +class CursorDeepFirstSearchTests: XCTestCase { + class TN { + var parent: TN? + var value: Int + var children: [TN] = [] + + init(_ value: Int, _ children: [TN] = []) { + self.value = value + self.children = children + children.forEach { $0.parent = self } + } + } + + class ACursor: Cursor { + var currentNode: TN? + init(currentNode: TN?) { + self.currentNode = currentNode + } + + func goToFirstChild() -> Bool { + if let first = currentNode?.children.first { + currentNode = first + return true + } + return false + } + + func goToNextSibling() -> Bool { + if let parent = currentNode?.parent, + let index = parent.children.firstIndex(where: { $0 === currentNode }), + index < parent.children.count - 1 { + currentNode = parent.children[index + 1] + return true + } + return false + } + + func goToParent() -> Bool { + if let parent = currentNode?.parent { + currentNode = parent + return true + } + return false + } + } + + func test_deep_first_search() { + let root = TN(0, [ + TN(1, [ + TN(2), + TN(3) + ]), + TN(4, [ + TN(5, [TN(6, [TN(7)])]), + TN(8) + ]) + ]) + let cursor = ACursor(currentNode: root) + var result = [Int]() + for node in CursorDeepFirstSearchSequence(cursor: cursor, skipChildren: { _ in true }) { + result.append(node.value) + } + + XCTAssertEqual(result, result.sorted()) + } + + func test_deep_first_search_skip_children() { + let root = TN(0, [ + TN(1, [ + TN(2), + TN(3) + ]), + TN(4, [ + TN(5, [TN(6, [TN(7)])]), + TN(8) + ]) + ]) + let cursor = ACursor(currentNode: root) + var result = [Int]() + for node in CursorDeepFirstSearchSequence(cursor: cursor, skipChildren: { $0.value == 5 }) { + result.append(node.value) + } + + XCTAssertEqual(result, [0, 1, 2, 3, 4, 5, 8]) + } +} diff --git a/Tool/Tests/ActiveDocumentChatContextCollectorTests/File.swift b/Tool/Tests/ActiveDocumentChatContextCollectorTests/File.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Tool/Tests/ActiveDocumentChatContextCollectorTests/File.swift @@ -0,0 +1 @@ + diff --git a/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift new file mode 100644 index 0000000..af9d3c0 --- /dev/null +++ b/Tool/Tests/FocusedCodeFinderTests/ObjectiveCFocusedCodeFinderTests.swift @@ -0,0 +1,462 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import FocusedCodeFinder + +final class ObjectiveCFocusedCodeFinder_Selection_Tests: XCTestCase { + func test_selecting_a_line_inside_the_method_the_scope_should_be_the_method() { + let code = """ + @implementation Foo + - (void)fooWith:(NSInteger)foo { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + @end + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 4) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@implementation Foo", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (6, 4)) + ), + .init( + signature: "- (void)fooWith:(NSInteger)foo", + name: "fooWith:(NSInteger)foo", + range: .init(startPair: (1, 0), endPair: (5, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 4)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + NSInteger foo = 0; + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_a_function_the_scope_should_be_the_function() { + let code = """ + void foo(char name[]) { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + """ + let range = CursorRange(startPair: (2, 0), endPair: (2, 4)) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "void foo(char name[])", + name: "foo", + range: .init(startPair: (0, 0), endPair: (4, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 1)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + NSLog(@"Hello"); + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_method_inside_an_implementation_the_scope_should_be_the_implementation() { + let code = """ + __attribute__((objc_nonlazy_class)) + @implementation Foo (Category) + - (void)fooWith:(NSInteger)foo { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + @end + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 6, character: 1) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "__attribute__((objc_nonlazy_class)) @implementation Foo (Category)", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (7, 4)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (7, 4)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + - (void)fooWith:(NSInteger)foo { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_an_interface_the_scope_should_be_the_interface() { + let code = """ + @interface ViewController >: NSObject + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + @end + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@interface ViewController>: NSObject", + name: "ViewController", + range: .init(startPair: (0, 0), endPair: (4, 4)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 4)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_an_interface_category_the_scope_should_be_the_interface() { + let code = """ + @interface __GENERICS(NSArray, ObjectType) (BlocksKit) + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + @end + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@interface __GENERICS(NSArray, ObjectType) (BlocksKit)", + name: "NSArray", + range: .init(startPair: (0, 0), endPair: (4, 4)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 4)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_a_protocol_the_scope_should_be_the_protocol() { + let code = """ + @protocol Foo + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + @end + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@protocol Foo", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (4, 4)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 4)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_a_struct_the_scope_should_be_the_struct() { + let code = """ + struct Foo { + NSInteger foo; + NSInteger bar; + NSInteger baz; + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "struct Foo", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (4, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 1)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + NSInteger foo; + NSInteger bar; + NSInteger baz; + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_a_enum_the_scope_should_be_the_enum() { + let code = """ + enum Foo { + foo, + bar, + baz + }; + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "enum Foo", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (4, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 1)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + foo, + bar, + baz + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_an_NSEnum_the_scope_should_be_the_enum() { + let code = """ + typedef NS_ENUM(NSInteger, Foo) { + foo, + bar, + baz + }; + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 3, character: 31) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "typedef NS_ENUM(NSInteger, Foo)", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (4, 2)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (4, 2)), + smallestContextRange: range, + focusedRange: range, + focusedCode: """ + foo, + bar, + baz + + """, + imports: [], + includes: [] + )) + } +} + +final class ObjectiveCFocusedCodeFinder_Focus_Tests: XCTestCase { + func test_get_focused_code_inside_method_the_method_should_be_the_focused_code() { + let code = """ + @implementation Foo + - (void)fooWith:(NSInteger)foo { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + @end + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 0) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@implementation Foo", + name: "Foo", + range: .init(startPair: (0, 0), endPair: (6, 4)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 4)), + smallestContextRange: .init(startPair: (1, 0), endPair: (5, 1)), + focusedRange: .init(startPair: (1, 0), endPair: (5, 1)), + focusedCode: """ + - (void)fooWith:(NSInteger)foo { + NSInteger foo = 0; + NSLog(@"Hello"); + NSLog(@"World"); + } + + """, + imports: [], + includes: [] + )) + } + + func test_get_focused_code_inside_an_interface_category_the_focused_code_should_be_the_interface( + ) { + let code = """ + @interface __GENERICS(NSArray, ObjectType) (BlocksKit) + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + @end + + @implementation Foo + @end + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 0) + ) + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .file, + contextRange: .init(startPair: (0, 0), endPair: (0, 0)), + smallestContextRange: .init(startPair: (0, 0), endPair: (4, 4)), + focusedRange: .init(startPair: (0, 0), endPair: (4, 4)), + focusedCode: """ + @interface __GENERICS(NSArray, ObjectType) (BlocksKit) + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + - (void)fooWith:(NSInteger)foo; + @end + + """, + imports: [], + includes: [] + )) + } +} + +final class ObjectiveCFocusedCodeFinder_Imports_Tests: XCTestCase { + func test_parsing_imports() { + let code = """ + #import + @import UIKit; + #import "Foo.h" + #include "Bar.h" + """ + + let context = ObjectiveCFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: .zero + ) + + XCTAssertEqual(context.imports, [ + "", + "UIKit", + "\"Foo.h\"", + ]) + XCTAssertEqual(context.includes, [ + "\"Bar.h\"", + ]) + } +} + diff --git a/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift new file mode 100644 index 0000000..666b614 --- /dev/null +++ b/Tool/Tests/FocusedCodeFinderTests/SwiftFocusedCodeFinderTests.swift @@ -0,0 +1,507 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import FocusedCodeFinder + +func document(code: String) -> FocusedCodeFinder.Document { + .init( + documentURL: URL(fileURLWithPath: "/"), + content: code, + lines: code.components(separatedBy: "\n").map { "\($0)\n" } + ) +} + +final class SwiftFocusedCodeFinder_Selection_Tests: XCTestCase { + func test_selecting_a_line_inside_the_function_the_scope_should_be_the_function() { + let code = """ + public struct A: B, C { + @ViewBuilder private func f(_ a: String) -> String { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 4, character: 0), + end: CursorPosition(line: 4, character: 13) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "public struct A: B, C", + name: "A", + range: .init(startPair: (0, 0), endPair: (8, 1)) + ), + .init( + signature: "@ViewBuilder private func f(_ a: String) -> String", + name: "f", + range: .init(startPair: (1, 4), endPair: (7, 5)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + smallestContextRange: .init(startPair: (4, 0), endPair: (4, 13)), + focusedRange: .init(startPair: (4, 0), endPair: (4, 13)), + focusedCode: """ + let c = 3 + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_function_inside_a_struct_the_scope_should_be_the_struct() { + let code = """ + @MainActor + public struct A: B, C { + func f() { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 7, character: 5) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@MainActor public struct A: B, C", + name: "A", + range: .init(startPair: (0, 0), endPair: (9, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (9, 1)), + smallestContextRange: .init(startPair: (2, 0), endPair: (7, 5)), + focusedRange: .init(startPair: (2, 0), endPair: (7, 5)), + focusedCode: """ + func f() { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_variable_inside_a_class_the_scope_should_be_the_class() { + let code = """ + @MainActor final public class A: P, K { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@MainActor final public class A: P, K", + name: "A", + range: .init(startPair: (0, 0), endPair: (6, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + smallestContextRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + var a = 1 + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_function_inside_a_protocol_the_scope_should_be_the_protocol() { + let code = """ + public protocol A: Hashable { + func f() + func g() + func h() + func i() + func j() + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "public protocol A: Hashable", + name: "A", + range: .init(startPair: (0, 0), endPair: (6, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + smallestContextRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + func f() + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_variable_inside_an_extension_the_scope_should_be_the_extension() { + let code = """ + private extension A: Equatable { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + var e = 5 + } + """ + let range = CursorRange( + start: CursorPosition(line: 1, character: 0), + end: CursorPosition(line: 1, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "private extension A: Equatable", + name: "A", + range: .init(startPair: (0, 0), endPair: (6, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (6, 1)), + smallestContextRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedRange: .init(startPair: (1, 0), endPair: (1, 9)), + focusedCode: """ + var a = 1 + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_static_function_from_an_actor_the_scope_should_be_the_actor() { + let code = """ + @gloablActor + public actor A { + static func f() {} + static func g() {} + static func h() {} + static func i() {} + static func j() {} + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@gloablActor public actor A", + name: "A", + range: .init(startPair: (0, 0), endPair: (7, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (7, 1)), + smallestContextRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedCode: """ + static func f() {} + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_case_inside_an_enum_the_scope_should_be_the_enum() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange( + start: CursorPosition(line: 3, character: 0), + end: CursorPosition(line: 3, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "@MainActor public indirect enum A", + name: "A", + range: .init(startPair: (0, 0), endPair: (8, 1)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + smallestContextRange: .init(startPair: (3, 0), endPair: (3, 9)), + focusedRange: .init(startPair: (3, 0), endPair: (3, 9)), + focusedCode: """ + case a + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_inside_computed_variable_the_scope_should_be_the_variable() { + let code = """ + struct A { + @SomeWrapper public private(set) var a: Int { + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + } + } + """ + let range = CursorRange( + start: CursorPosition(line: 2, character: 0), + end: CursorPosition(line: 2, character: 9) + ) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: .max).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .scope(signature: [ + .init( + signature: "struct A", + name: "A", + range: .init(startPair: (0, 0), endPair: (8, 1)) + ), + .init( + signature: "@SomeWrapper public private(set) var a: Int", + name: "a", + range: .init(startPair: (1, 4), endPair: (7, 5)) + ), + ]), + contextRange: .init(startPair: (0, 0), endPair: (8, 1)), + smallestContextRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedRange: .init(startPair: (2, 0), endPair: (2, 9)), + focusedCode: """ + let a = 1 + + """, + imports: [], + includes: [] + )) + } + + func test_selecting_a_line_in_freestanding_macro_the_scope_should_be_the_macro() { + // TODO: + } +} + +final class SwiftFocusedCodeFinder_FocusedCode_Tests: XCTestCase { + func test_get_focused_code_on_top_level_should_fallback_to_unknown_language() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + + func hello() { + print("hello") + print("hello") + } + """ + let range = CursorRange(startPair: (0, 0), endPair: (0, 0)) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 1000).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (13, 2)), + smallestContextRange: .init(startPair: (0, 0), endPair: (13, 2)), + focusedRange: .init(startPair: (0, 0), endPair: (13, 2)), + focusedCode: """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + + func hello() { + print("hello") + print("hello") + } + + """, + imports: [], + includes: [] + )) + } + + func test_get_focused_code_inside_enum_the_whole_enum_will_be_the_focused_code() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange(startPair: (3, 0), endPair: (3, 0)) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 1000).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .file, + contextRange: .init(startPair: (0, 0), endPair: (0, 0)), + smallestContextRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedCode: """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + + """, + imports: [], + includes: [] + )) + } + + func test_get_focused_code_inside_enum_with_limited_max_line_count() { + let code = """ + @MainActor + public + indirect enum A { + case a + case b + case c + case d + case e + } + """ + let range = CursorRange(startPair: (3, 0), endPair: (3, 0)) + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 3).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context, .init( + scope: .file, + contextRange: .init(startPair: (0, 0), endPair: (0, 0)), + smallestContextRange: .init(startPair: (0, 0), endPair: (8, 1)), + focusedRange: .init(startPair: (2, 0), endPair: (4, 11)), + focusedCode: """ + indirect enum A { + case a + case b + + """, + imports: [], + includes: [] + )) + } +} + +final class SwiftFocusedCodeFinder_Import_Tests: XCTestCase { + func test_parsing_imports() { + let code = """ + import OnTop + import Second + import Third + + struct Foo { + + } + + import BelowStructFoo + + class Bar { + + } + + import BelowClassBar + """ + + let range = CursorRange.zero + let context = SwiftFocusedCodeFinder(maxFocusedCodeLineCount: 3).findFocusedCode( + in: document(code: code), + containingRange: range + ) + XCTAssertEqual(context.imports, [ + "OnTop", + "Second", + "Third", + "BelowStructFoo", + "BelowClassBar", + ]) + } +} + diff --git a/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift b/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift new file mode 100644 index 0000000..fa975db --- /dev/null +++ b/Tool/Tests/FocusedCodeFinderTests/UnknownLanguageFocusedCodeFinderTests.swift @@ -0,0 +1,98 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import FocusedCodeFinder + +class UnknownLanguageFocusedCodeFinderTests: XCTestCase { + func test_the_code_is_long_enough_for_the_search_range() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + in: document(code: code), + containingRange: .init(startPair: (50, 0), endPair: (50, 0)) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (40, 0), endPair: (60, 3)), + smallestContextRange: .init(startPair: (40, 0), endPair: (60, 3)), + focusedRange: .init(startPair: (45, 0), endPair: (55, 3)), + focusedCode: stride(from: 45, through: 55, by: 1).map { "\($0)\n" }.joined(), + imports: [], + includes: [] + )) + } + + func test_the_upper_side_is_not_long_enough_expand_the_lower_end() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + in: document(code: code), + containingRange: .init(startPair: (2, 0), endPair: (2, 0)) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (15, 3)), + smallestContextRange: .init(startPair: (0, 0), endPair: (15, 3)), + focusedRange: .init(startPair: (0, 0), endPair: (10, 3)), + focusedCode: stride(from: 0, through: 10, by: 1).map { "\($0)\n" }.joined(), + imports: [], + includes: [] + )) + } + + func test_the_lower_side_is_not_long_enough_do_not_expand_the_upper_end() { + let code = stride(from: 0, through: 100, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + in: document(code: code), + containingRange: .init(startPair: (99, 0), endPair: (99, 0)) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (89, 0), endPair: (101, 1)), + smallestContextRange: .init(startPair: (89, 0), endPair: (101, 1)), + focusedRange: .init(startPair: (94, 0), endPair: (101, 1)), + focusedCode: stride(from: 94, through: 100, by: 1).map { "\($0)\n" }.joined() + "\n", + imports: [], + includes: [] + )) + } + + func test_both_sides_are_just_long_enough() { + let code = stride(from: 0, through: 10, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + in: document(code: code), + containingRange: .init(startPair: (5, 0), endPair: (5, 0)) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (11, 1)), + smallestContextRange: .init(startPair: (0, 0), endPair: (11, 1)), + focusedRange: .init(startPair: (0, 0), endPair: (10, 3)), + focusedCode: code, + imports: [], + includes: [] + )) + } + + func test_both_sides_are_not_long_enough() { + let code = stride(from: 0, through: 4, by: 1).map { "\($0)\n" }.joined() + let context = UnknownLanguageFocusedCodeFinder(proposedSearchRange: 5) + .findFocusedCode( + in: document(code: code), + containingRange: .init(startPair: (3, 0), endPair: (3, 0)) + ) + XCTAssertEqual(context, .init( + scope: .top, + contextRange: .init(startPair: (0, 0), endPair: (5, 1)), + smallestContextRange: .init(startPair: (0, 0), endPair: (5, 1)), + focusedRange: .init(startPair: (0, 0), endPair: (5, 1)), + focusedCode: code + "\n", + imports: [], + includes: [] + )) + } +} + diff --git a/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift new file mode 100644 index 0000000..8bd8134 --- /dev/null +++ b/Tool/Tests/GitHubCopilotServiceTests/FetchSuggestionsTests.swift @@ -0,0 +1,108 @@ +import CopilotForXcodeKit +import LanguageServerProtocol +import XCTest + +@testable import Workspace +@testable import GitHubCopilotService + +struct TestServiceLocator: ServiceLocatorType { + let server: GitHubCopilotLSP + func getService(from workspace: WorkspaceInfo) async -> GitHubCopilotService? { + .init(designatedServer: server) + } +} + +final class FetchSuggestionTests: XCTestCase { + func test_process_suggestions_from_server() async throws { + struct TestServer: GitHubCopilotLSP { + func sendNotification(_: LanguageServerProtocol.ClientNotification) async throws { + throw CancellationError() + } + + func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { + return GitHubCopilotRequest.InlineCompletion.Response(items: [ + .init( + insertText: "Hello World\n", + filterText: nil, + range: .init(start: .init((0, 0)), end: .init((0, 4))), + command: nil + ), + .init( + insertText: " ", + filterText: nil, + range: .init(start: .init((0, 0)), end: .init((0, 1))), + command: nil + ), + .init( + insertText: " \n", + filterText: nil, + range: .init(start: .init((0, 0)), end: .init((0, 2))), + command: nil + ), + ]) as! E.Response + } + } + let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: TestServer())) + let completions = try await service.getSuggestions( + .init( + fileURL: .init(fileURLWithPath: "/file.swift"), + relativePath: "", + language: .builtIn(.swift), + content: "", + originalContent: "", + cursorPosition: .outOfScope, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ), + workspace: .init( + workspaceURL: .init(fileURLWithPath: "/"), + projectURL: .init(fileURLWithPath: "/file.swift") + ) + ) + XCTAssertEqual(completions.count, 3) + } + + func test_if_language_identifier_is_unknown_returns_correctly() async throws { + class TestServer: GitHubCopilotLSP { + func sendNotification(_: LanguageServerProtocol.ClientNotification) async throws { + // unimplemented + } + + func sendRequest(_: E) async throws -> E.Response where E: GitHubCopilotRequestType { + return GitHubCopilotRequest.InlineCompletion.Response(items: [ + .init( + insertText: "Hello World\n", + filterText: nil, + range: .init(start: .init((0, 0)), end: .init((0, 4))), + command: nil + ), + ]) as! E.Response + } + } + let testServer = TestServer() + let service = GitHubCopilotSuggestionService(serviceLocator: TestServiceLocator(server: testServer)) + let completions = try await service.getSuggestions( + .init( + fileURL: .init(fileURLWithPath: "/"), + relativePath: "", + language: .builtIn(.swift), + content: "", + originalContent: "", + cursorPosition: .outOfScope, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ), + workspace: .init( + workspaceURL: .init(fileURLWithPath: "/"), + projectURL: .init(fileURLWithPath: "/file.swift") + ) + ) + XCTAssertEqual(completions.count, 1) + XCTAssertEqual(completions.first?.text, "Hello World\n") + } +} + diff --git a/Tool/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift b/Tool/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift new file mode 100644 index 0000000..1054e45 --- /dev/null +++ b/Tool/Tests/GitHubCopilotServiceTests/FileExtensionToLanguageIdentifierTests.swift @@ -0,0 +1,21 @@ +import LanguageServerProtocol +import XCTest + +@testable import GitHubCopilotService + +final class FileExtensionToLanguageIdentifierTests: XCTestCase { + func test_no_conflicts_in_map() { + var dict = [String: [String]]() + for languageId in LanguageIdentifier.allCases { + for e in languageId.fileExtensions { + if dict[e] == nil { + dict[e] = [] + } + dict[e]?.append(languageId.rawValue) + } + } + + let confilicts = dict.filter { $0.value.count > 1 } + XCTAssertEqual(confilicts, [:]) + } +} diff --git a/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift b/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift new file mode 100644 index 0000000..442b25a --- /dev/null +++ b/Tool/Tests/GitHubCopilotServiceTests/SystemInfoTests.swift @@ -0,0 +1,20 @@ +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/SharedUIComponentsTests/ConvertToCodeLinesTests.swift b/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift new file mode 100644 index 0000000..7ad5412 --- /dev/null +++ b/Tool/Tests/SharedUIComponentsTests/ConvertToCodeLinesTests.swift @@ -0,0 +1,159 @@ +import XCTest + +@testable import SharedUIComponents + +final class ConvertToCodeLinesTests: XCTestCase { + func test_do_not_remove_common_leading_spaces() async throws { + let code = """ + struct Cat { + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "swift", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: false, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 0) + print(code.replacingOccurrences(of: " ", with: "ยท")) + XCTAssertEqual(result.map(\.string), [ + " struct Cat {", + " }", + ]) + } + + func test_wont_remove_common_leading_spaces_2_spaces() async throws { + let code = """ + struct Cat { + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 0) + XCTAssertEqual(result.map(\.string), [ + " struct Cat {", + " }", + ]) + } + + func test_remove_common_leading_spaces_4_spaces() async throws { + let code = """ + struct Cat { + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 4) + XCTAssertEqual(result.map(\.string), [ + "struct Cat {", + "}", + ]) + } + + func test_remove_common_leading_spaces_8_spaces() async throws { + let code = """ + struct Cat { + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 8) + XCTAssertEqual(result.map(\.string), [ + "struct Cat {", + "}", + ]) + } + + func test_remove_common_leading_spaces_one_line_is_empty() async throws { + let code = """ + struct Cat { + + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 4) + XCTAssertEqual(result.map(\.string), [ + "struct Cat {", + "", + "}", + ]) + } + + func test_remove_common_leading_spaces_one_line_has_no_leading_spaces() async throws { + let code = """ + struct Cat { + // + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 0) + XCTAssertEqual(result.map(\.string), [ + " struct Cat {", + "//", + " }", + ]) + } + + func test_remove_common_leading_spaces_one_line_has_fewer_leading_spaces() async throws { + let code = """ + struct Cat { + // + } + """ + let (result, spaceCount) = CodeHighlighting.highlighted( + code: code, + language: "md", + scenario: "a", + brightMode: true, + droppingLeadingSpaces: true, + font: .systemFont(ofSize: 14) + ) + + XCTAssertEqual(spaceCount, 4) + XCTAssertEqual(result.map(\.string), [ + " struct Cat {", + "//", + " }", + ]) + } +} diff --git a/Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift b/Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift new file mode 100644 index 0000000..6f2ff5a --- /dev/null +++ b/Tool/Tests/SuggestionBasicTests/BreakLinePerformanceTests.swift @@ -0,0 +1,18 @@ +import Foundation +import XCTest +@testable import SuggestionBasic + +final class BreakLinePerformanceTests: XCTestCase { + func test_breakLines() { + let string = String(repeating: """ + Hello + World + + """, count: 50000) + + measure { + let _ = string.breakLines() + } + } +} + diff --git a/Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift b/Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift new file mode 100644 index 0000000..8ff71f9 --- /dev/null +++ b/Tool/Tests/SuggestionBasicTests/LineAnnotationParsingTests.swift @@ -0,0 +1,14 @@ +import Foundation +import XCTest + +@testable import SuggestionBasic + +class LineAnnotationParsingTests: XCTestCase { + func test_parse_line_annotation() { + let annotation = "Error Line 25: FileName.swift:25 Cannot convert Type" + let parsed = EditorInformation.parseLineAnnotation(annotation) + XCTAssertEqual(parsed.type, "Error") + XCTAssertEqual(parsed.line, 25) + XCTAssertEqual(parsed.message, "Cannot convert Type") + } +} diff --git a/Tool/Tests/SuggestionBasicTests/ModificationTests.swift b/Tool/Tests/SuggestionBasicTests/ModificationTests.swift new file mode 100644 index 0000000..0de6149 --- /dev/null +++ b/Tool/Tests/SuggestionBasicTests/ModificationTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import SuggestionBasic + +final class ModificationTests: XCTestCase { + func test_nsmutablearray_deleting_an_element() { + let a = NSMutableArray(array: ["a", "b", "c"]) + a.apply([.deleted(0...0)]) + XCTAssertEqual(a as! [String], ["b", "c"]) + } + + func test_nsmutablearray_deleting_all_element() { + let a = NSMutableArray(array: ["a", "b", "c"]) + a.apply([.deleted(0...2)]) + XCTAssertEqual(a as! [String], []) + } + + func test_nsmutablearray_deleting_too_much_element() { + let a = NSMutableArray(array: ["a", "b", "c"]) + a.apply([.deleted(0...100)]) + XCTAssertEqual(a as! [String], []) + } + + func test_nsmutablearray_inserting_elements() { + let a = NSMutableArray(array: ["a", "b", "c"]) + a.apply([.inserted(0, ["y", "z"])]) + XCTAssertEqual(a as! [String], ["y", "z", "a", "b", "c"]) + a.apply([.inserted(1, ["0", "1"])]) + XCTAssertEqual(a as! [String], ["y", "0", "1", "z", "a", "b", "c"]) + } + + func test_nsmutablearray_inserting_elements_at_index_out_of_range() { + let a = NSMutableArray(array: ["a", "b", "c"]) + a.apply([.inserted(1000, ["z"])]) + XCTAssertEqual(a as! [String], ["a", "b", "c", "z"]) + } +} diff --git a/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift b/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift new file mode 100644 index 0000000..7b1fa00 --- /dev/null +++ b/Tool/Tests/SuggestionBasicTests/TextExtrationFromCodeTests.swift @@ -0,0 +1,157 @@ +import Foundation +import XCTest +@testable import SuggestionBasic + +final class TextExtrationFromCodeTests: XCTestCase { + func test_empty_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 0), + end: CursorPosition(line: 0, character: 0) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_single_line_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "foo = ") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_single_line_selection_with_emoji() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let ๐ŸŽ†๐ŸŽ†o = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "๐ŸŽ†๐ŸŽ†o ") + XCTAssertEqual(result.lines, ["let ๐ŸŽ†๐ŸŽ†o = 1\n"]) + } + + func test_single_line_selection_cutting_emoji() { + // undefined behavior + + let selection = CursorRange( + start: CursorPosition(line: 0, character: 5), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let ๐ŸŽ†๐ŸŽ†o = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.lines, ["let ๐ŸŽ†๐ŸŽ†o = 1\n"]) + } + + func test_single_line_selection_at_line_end() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 8), + end: CursorPosition(line: 0, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "= 1") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_multi_line_selection() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "foo = 1\nlet bar = 2") + XCTAssertEqual(result.lines, ["let foo = 1\n", "let bar = 2\n"]) + } + + func test_multi_line_selection_with_emoji() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["๐ŸŽ†๐ŸŽ† foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, " foo = 1\nlet bar = 2") + XCTAssertEqual(result.lines, ["๐ŸŽ†๐ŸŽ† foo = 1\n", "let bar = 2\n"]) + } + + func test_invalid_selection() { + let selection = CursorRange( + start: CursorPosition(line: 1, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1", "let bar = 2"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: false + ) + XCTAssertEqual(result.code, "") + XCTAssertEqual(result.lines, []) + } + + func test_single_line_selection_ignoring_column() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 0, character: 10) + ) + let lines = ["let foo = 1\n", "let bar = 2\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: true + ) + XCTAssertEqual(result.code, "let foo = 1\n") + XCTAssertEqual(result.lines, ["let foo = 1\n"]) + } + + func test_multi_line_selection_ignoring_column() { + let selection = CursorRange( + start: CursorPosition(line: 0, character: 4), + end: CursorPosition(line: 1, character: 11) + ) + let lines = ["let foo = 1\n", "let bar = 2\n", "let baz = 3\n"] + let result = EditorInformation.code( + in: lines, + inside: selection, + ignoreColumns: true + ) + XCTAssertEqual(result.code, "let foo = 1\nlet bar = 2\n") + XCTAssertEqual(result.lines, ["let foo = 1\n", "let bar = 2\n"]) + } +} + diff --git a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift new file mode 100644 index 0000000..ac38995 --- /dev/null +++ b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift @@ -0,0 +1,188 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import SuggestionProvider + +class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase { + func createRequest( + _ code: String = "", + _ cursorPosition: CursorPosition = .zero + ) -> SuggestionRequest { + let lines = code.breakLines() + return SuggestionRequest( + fileURL: URL(fileURLWithPath: "/path/to/file.swift"), + relativePath: "file.swift", + content: code, + originalContent: code, + lines: lines, + cursorPosition: cursorPosition, + cursorOffset: { + if cursorPosition == .outOfScope { return 0 } + let prefixLines = if cursorPosition.line > 0 { + lines[0.. + } + let offset = prefixLines.reduce(0) { $0 + $1.utf8.count } + return offset + + lines[cursorPosition.line].prefix(cursorPosition.character).utf8.count + }(), + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ) + } + + func test_trailing_whitespaces_and_new_lines_should_be_removed() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest(), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ]) + } + + func test_remove_suggestions_that_contains_only_whitespaces_and_new_lines() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: " \n\n\r", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "3", + text: " ", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "4", + text: "\n\n\n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest(), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + ]) + } + + func test_remove_suggestion_that_takes_no_effect_after_being_accepted() async throws { + let middleware = PostProcessingSuggestionServiceMiddleware() + + let handler: PostProcessingSuggestionServiceMiddleware.Next = { _ in + [ + .init( + id: "1", + text: "hello world \n \n", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "2", + text: "let cat = 100", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + .init( + id: "3", + text: "let cat = 10", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + ] + } + + let suggestions = try await middleware.getSuggestion( + createRequest("let cat = 100", .init(line: 0, character: 3)), + configuration: .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: true, + acceptsRelevantSnippetsFromOpenedFiles: true + ), + next: handler + ) + + XCTAssertEqual(suggestions, [ + .init( + id: "1", + text: "hello world", + position: .init(line: 0, character: 0), + range: .init(startPair: (0, 0), endPair: (0, 0)) + ), + .init( + id: "3", + text: "let cat = 10", + position: .init(line: 0, character: 13), + range: .init(startPair: (0, 0), endPair: (0, 13)) + ), + ]) + } +} + diff --git a/Tool/Tests/WorkspaceSuggestionServiceTests/FilespaceSuggestionSnapshotTests.swift b/Tool/Tests/WorkspaceSuggestionServiceTests/FilespaceSuggestionSnapshotTests.swift new file mode 100644 index 0000000..f20fa66 --- /dev/null +++ b/Tool/Tests/WorkspaceSuggestionServiceTests/FilespaceSuggestionSnapshotTests.swift @@ -0,0 +1,97 @@ +import XCTest +import SuggestionBasic +import WorkspaceSuggestionService + +final class FilespaceSuggestionSnapshotTests: XCTestCase { + + func testSameContent_IsEqual() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + + XCTAssertTrue(a == b) + } + + func testDifferenentContent_IsNotEqual() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["on","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + + XCTAssertFalse(a == b) + } + + func testEqualOrCurrentLineDiffers_WithNoChange_ReturnsTrue() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + + XCTAssertTrue(a.equalOrOnlyCurrentLineDiffers(comparedTo: b)) + } + + func testEqualOrCurrentLineDiffers_WithOnlyCurrentChange_ReturnsTrue() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one","two","t"], + cursorPosition: CursorPosition(line: 2, character: 1) + ) + + XCTAssertTrue(a.equalOrOnlyCurrentLineDiffers(comparedTo: b)) + } + + func testEqualOrCurrentLineDiffers_WithPositionChange_ReturnsFalse() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 1, character: 0) + ) + + XCTAssertFalse(a.equalOrOnlyCurrentLineDiffers(comparedTo: b)) + } + + func testEqualOrCurrentLineDiffers_WithPrefixChange_ReturnsFalse() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","two",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one","one",""], + cursorPosition: CursorPosition(line: 2, character: 0) + ) + + XCTAssertFalse(a.equalOrOnlyCurrentLineDiffers(comparedTo: b)) + } + + func testEqualOrCurrentLineDiffers_WithSuffixChange_ReturnsFalse() throws { + let a = FilespaceSuggestionSnapshot( + lines: ["one","","three"], + cursorPosition: CursorPosition(line: 1, character: 0) + ) + let b = FilespaceSuggestionSnapshot( + lines: ["one",""], + cursorPosition: CursorPosition(line: 1, character: 0) + ) + + XCTAssertFalse(a.equalOrOnlyCurrentLineDiffers(comparedTo: b)) + } +} diff --git a/Tool/Tests/WorkspaceSuggestionServiceTests/LineEditTests.swift b/Tool/Tests/WorkspaceSuggestionServiceTests/LineEditTests.swift new file mode 100644 index 0000000..0cfb1ec --- /dev/null +++ b/Tool/Tests/WorkspaceSuggestionServiceTests/LineEditTests.swift @@ -0,0 +1,161 @@ +import SuggestionBasic +import WorkspaceSuggestionService +import XCTest + +final class LineEditTests: XCTestCase { + + func lineAndCursorPos(from str: String) -> (String, CursorPosition) { + let parts = str.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + let pos = CursorPosition(line: 0, character: parts.first?.count ?? 0) + return (parts.joined(), pos) + } + + func suggestion(_ line: String, rangeLength: Int = 0) -> CodeSuggestion { + let (text, position) = lineAndCursorPos(from: line) + return CodeSuggestion( + id: "", + text: text, + position: position, + range: .init(startPair: (0, 0), endPair: (0, rangeLength)) + ) + } + + func edit(from: String, to: String, suggested: String) -> LineEdit { + // replacement range is the full length of the original line (minus line ending and cursor placeholder) + return edit(from: from, to: to, suggested: suggested, rangeLength: max(0, from.count - 2)) + } + + func edit(from: String, to: String, suggested: String, rangeLength: Int) -> LineEdit { + let (fromLine, fromPos) = lineAndCursorPos(from: from) + let (toLine, toPos) = lineAndCursorPos(from: to) + + return LineEdit( + snapshot: .init( + lines: [fromLine], + cursorPosition: fromPos + ), + suggestion: suggestion(suggested, rangeLength: rangeLength), + lines: [toLine], + cursor: toPos + ) + } + + // MARK: .init + + func testInit_EmptyLine() throws { + let edit = edit(from: "|", to: "|", suggested: "|// hello") + + XCTAssertEqual(edit.line, "") + XCTAssertEqual(edit.userEntered, "") + XCTAssertEqual(edit.head, "") + XCTAssertEqual(edit.tail, "") + } + + func testInit_NoTail() throws { + let edit = edit(from: "let one |\n", to: "let one =|\n", suggested: "let one |= 1") + + XCTAssertEqual(edit.line, "let one =") + XCTAssertEqual(edit.userEntered, "let one =") + XCTAssertEqual(edit.head, "let one =") + XCTAssertEqual(edit.tail, "") + } + + func testInit_PreservesExistingTail() throws { + let edit = edit( + from: "let fourTuple = (1, |)\n", + to: "let fourTuple = (1, 2|)\n", + suggested: "let fourTuple = (1, |2, 3, 4)" + ) + + XCTAssertEqual(edit.line, "let fourTuple = (1, 2)") + XCTAssertEqual(edit.userEntered, "let fourTuple = (1, 2") + XCTAssertEqual(edit.head, "let fourTuple = (1, 2") + XCTAssertEqual(edit.tail, ")") + } + + func testInit_NewBraceCompletionIncludedInTail() throws { + let edit = edit( + from: "let nestedTuple = (1, |)\n", + to: "let nestedTuple = (1, (2, (3|)))\n", + suggested: "let nestedTuple = (1, |(2, (3, 4)))" + ) + + XCTAssertEqual(edit.line, "let nestedTuple = (1, (2, (3)))") + XCTAssertEqual(edit.userEntered, "let nestedTuple = (1, (2, (3") + XCTAssertEqual(edit.head, "let nestedTuple = (1, (2, (3") + XCTAssertEqual(edit.tail, ")))") + } + + func testInit_NonBraceCompletionNotIncludedInTail() throws { + let edit = edit( + from: "let nestedTuple = (1, |)\n", + to: "let nestedTuple = (1, (|2))\n", + suggested: "let nestedTuple = (1, |(2, (3, 4)))" + ) + + XCTAssertEqual(edit.line, "let nestedTuple = (1, (2))") + XCTAssertEqual(edit.userEntered, "let nestedTuple = (1, (2") + XCTAssertEqual(edit.head, "let nestedTuple = (1, (") + XCTAssertEqual(edit.tail, "))") + } + + // MARK: .updateSuggestions + + func testUpdateSuggestions_WithNoChanges_ReturnsSameSuggestions() { + let edit = edit(from: "|\n", to: "|\n", suggested: "|// hello") + + let suggestions = [suggestion("|// hello"), suggestion("|// hello there")] + let updated = edit.updateSuggestions(suggestions) + + XCTAssertEqual(updated, suggestions) + } + + func testUpdateSuggestions_WithTypingIntoSuggetion_AdjustsCursorPositionAndRange() { + let edit = edit(from: "|\n", to: "//|\n", suggested: "|// hello") + + let suggestions = [suggestion("|// hello"), suggestion("|// hello there")] + let updated = edit.updateSuggestions(suggestions) + + XCTAssertEqual(updated, [ + suggestion("//| hello", rangeLength: 2), + suggestion("//| hello there", rangeLength: 2) + ]) + } + + func testUpdateSuggestions_WithSameTail_PreservesSelectedRange() { + let edit = edit( + from: "let pos = (1|)\n", + to: "let pos = (1, |)\n", + suggested: "let pos = (1|, 1)" + ) + + let updated = edit.updateSuggestions([edit.suggestion]) + + XCTAssertEqual(updated, [suggestion("let pos = (1, |1)", rangeLength: 15)]) + } + + func testUpdateSuggestions_WithPartialLineRange_PreservesUnselectedPortion() { + let edit = edit( + from: "let pos = (1|) //\n", + to: "let pos = (1, |) //\n", + suggested: "let pos = (1|, 1)", + rangeLength: 13 + ) + + let updated = edit.updateSuggestions([edit.suggestion]) + + XCTAssertEqual(updated, [suggestion("let pos = (1, |1)", rangeLength: 15)]) + } + + func testUpdateSuggestions_WithNewBraceCompletion_ExtendsSelectedRange() { + let edit = edit( + from: "let nested = (1|)\n", + to: "let nested = (1, (2, |))\n", + suggested: "let nested = (1|, (2, 3))" + ) + + let updated = edit.updateSuggestions([edit.suggestion]) + + XCTAssertEqual(updated, [suggestion("let nested = (1, (2, |3))", rangeLength: 23)]) + } +} diff --git a/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift b/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift new file mode 100644 index 0000000..d62d0c0 --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/EditorRangeConversionTests.swift @@ -0,0 +1,231 @@ +import Foundation +import SuggestionBasic +import XCTest + +@testable import XcodeInspector + +class SourceEditorRangeConversionTests: XCTestCase { + // MARK: - Convert to CursorRange + + func test_convert_multiline_range() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 3)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 6)) + } + + func test_convert_multiline_range_with_special_line_endings() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """.replacingOccurrences(of: "\n", with: "\r\n") + + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 2)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_multiline_range_with_emoji() { + let code = """ + import Foundation + import ๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ† + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 21...42 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 3)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_multiline_range_cutting_emoji() { + // undefined behavior + + let code = """ + import Foundation + import ๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ† + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 26...42 // in the middle of the emoji + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .init(line: 1, character: 8)) + XCTAssertEqual(cursorRange.end, .init(line: 3, character: 3)) + } + + func test_convert_range_with_no_code() { + let code = "" + let range = 21...39 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + XCTAssertEqual(cursorRange.start, .zero) + XCTAssertEqual(cursorRange.end, .zero) + } + + func test_convert_multiline_range_with_out_of_range_cursor() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let range = 999...1000 + let cursorRange = SourceEditor.convertRangeToCursorRange(range, in: code) + + // undefined behavior + + XCTAssertEqual(cursorRange.start, .zero) + XCTAssertEqual(cursorRange.end, .init(line: 8, character: 0)) + } + + // MARK: - Convert to CFRange + + func test_back_convert_multiline_cursor_range() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 1, character: 3), + end: .init(line: 3, character: 6) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + XCTAssertEqual(range.range, 21...39) + } + + func test_back_convert_multiline_range_with_out_of_range_cursor() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 999, character: 0), + end: .init(line: 1000, character: 0) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + // undefined behavior + + XCTAssertEqual(range.range, 0...0) + } + + func test_back_convert_multiline_range_with_special_line_endings() { + let code = """ + import Foundation + import XCTest + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """.replacingOccurrences(of: "\n", with: "\r\n") + + let cursorRange = CursorRange( + start: .init(line: 1, character: 2), + end: .init(line: 3, character: 3) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + + XCTAssertEqual(range.range, 21...39) + } + + func test_back_convert_multiline_range_with_emoji() { + let code = """ + import Foundation + import ๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ†๐ŸŽ† + + class SourceEditorRangeConversionTests { + func testSomething() { + // test + } + } + + """ + + let cursorRange = CursorRange( + start: .init(line: 1, character: 3), + end: .init(line: 3, character: 3) + ) + let range = SourceEditor.convertCursorRangeToRange(cursorRange, in: code) + XCTAssertEqual(range.range, 21...42) + } + + func test_back_convert_range_with_no_code() { + let code = "" + let range = 21...39 + let cursorRange = SourceEditor.convertCursorRangeToRange( + SourceEditor.convertRangeToCursorRange(range, in: code), + in: code + ) + + XCTAssertEqual(cursorRange.range, 0...0) + } +} + +private extension CFRange { + var range: ClosedRange { + return location...(location + length) + } +} + diff --git a/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift b/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift new file mode 100644 index 0000000..f3632d8 --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest + +@testable import XcodeInspector + +class SourceEditorCachePerformanceTests: XCTestCase { + func test_source_editor_cache_get_content_comparison() { + let content = String(repeating: """ + struct Cat: Animal { + var name: String + } + + """, count: 500) + let cache = SourceEditor.Cache(sourceContent: content + "Yes") + + measure { + for _ in 1 ... 10000 { + _ = cache.get(content: content, selectedTextRange: nil) + } + } + } +} + diff --git a/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift b/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift new file mode 100644 index 0000000..3649e3e --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest + +@testable import XcodeInspector + +class SourceEditorCacheTests: XCTestCase { + func test_source_editor_cache_get_content_thread_safe() { + func randomContent() -> String { + String(repeating: """ + struct Cat: Animal { + var name: String + } + + """, count: Int.random(in: 2...10)) + } + + func randomSelectionRange() -> ClosedRange { + let random = Int.random(in: 0...20) + return random...random + } + + let cache = SourceEditor.Cache() + + let max = 5000 + let exp = expectation(description: "test_source_editor_cache_get_content_thread_safe") + DispatchQueue.concurrentPerform(iterations: max) { count in + let content = randomContent() + let selectionRange = randomSelectionRange() + let result = cache.get(content: content, selectedTextRange: selectionRange) + + XCTAssertEqual(result.lines, content.breakLines(appendLineBreakToLastLine: false)) + XCTAssertEqual(result.selections, [SourceEditor.convertRangeToCursorRange( + selectionRange, + in: result.lines + )]) + + if max == count + 1 { + exp.fulfill() + } + } + + wait(for: [exp], timeout: 10) + } +} + diff --git a/Version.xcconfig b/Version.xcconfig new file mode 100644 index 0000000..82b2f1a --- /dev/null +++ b/Version.xcconfig @@ -0,0 +1,3 @@ +APP_VERSION = 0.0.0 +APP_BUILD = $(APP_VERSION) + diff --git a/bridgeLaunchAgent.plist b/bridgeLaunchAgent.plist new file mode 100644 index 0000000..a052db4 --- /dev/null +++ b/bridgeLaunchAgent.plist @@ -0,0 +1,15 @@ + + + + + Label + com.github.CopilotForXcode.CommunicationBridge + Program + /Applications/GitHub Copilot for Xcode.app/Contents/Applications/CommunicationBridge + MachServices + + com.github.CopilotForXcode.CommunicationBridge + + + + diff --git a/export-options.plist b/export-options.plist new file mode 100644 index 0000000..f8d0b35 --- /dev/null +++ b/export-options.plist @@ -0,0 +1,10 @@ + + + + + teamID + VEKTX9H2N7 + method + developer-id + + diff --git a/launchAgent.plist b/launchAgent.plist new file mode 100644 index 0000000..4770316 --- /dev/null +++ b/launchAgent.plist @@ -0,0 +1,15 @@ + + + + + Label + com.github.CopilotForXcode.ExtensionService + Program + /Applications/GitHub Copilot for Xcode.app/Contents/Applications/GitHub Copilot for Xcode Extension.app/Contents/MacOS/GitHub Copilot for Xcode Extension + MachServices + + com.github.CopilotForXcode.ExtensionService + + + +