diff --git a/.github/actions/set-xcode-version/action.yml b/.github/actions/set-xcode-version/action.yml
index 1a6bb6c9..4d8dc817 100644
--- a/.github/actions/set-xcode-version/action.yml
+++ b/.github/actions/set-xcode-version/action.yml
@@ -6,7 +6,7 @@ inputs:
Xcode version to use, in semver(ish)-style matching the format on the Actions runner image.
See available versions at https://github.com/actions/runner-images/blame/main/images/macos/macos-14-Readme.md#xcode
required: false
- default: '15.3'
+ default: '16.2'
outputs:
xcode-path:
description: "Path to current Xcode version"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ce6a416..d1e95450 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.33.0 - April 17, 2025
+### Added
+- Added support for new models in Chat: Claude 3.7 Sonnet and GPT 4.5
+- Implemented @workspace context feature allowing questions about the entire codebase in Copilot Chat
+
+### Changed
+- Simplified access to Copilot Chat from the Copilot for Xcode app with a single click
+- Enhanced instructions for granting background permissions
+
+### Fixed
+- Resolved false alarms for sign-in and free plan limit notifications
+- Improved app launch performance
+- Fixed workspace and context update issues
+
## 0.32.0 - March 11, 2025 (General Availability)
### Added
- Implemented model picker for selecting LLM model in chat
diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
index e34dee91..4e289e57 100644
--- a/CommunicationBridge/ServiceDelegate.swift
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -136,28 +136,100 @@ actor ExtensionServiceLauncher {
isLaunching = true
Logger.communicationBridge.info("Launching extension service app.")
-
- NSWorkspace.shared.openApplication(
- at: appURL,
- configuration: {
- let configuration = NSWorkspace.OpenConfiguration()
- configuration.createsNewApplicationInstance = false
- configuration.addsToRecentItems = false
- configuration.activates = false
- return configuration
- }()
- ) { app, error in
- if let error = error {
- Logger.communicationBridge.error(
- "Failed to launch extension service app: \(error)"
- )
- } else {
- Logger.communicationBridge.info(
- "Finished launching extension service app."
- )
+
+ // First check if the app is already running
+ if let runningApp = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == appIdentifier
+ }) {
+ Logger.communicationBridge.info("Extension service app already running with PID: \(runningApp.processIdentifier)")
+ self.application = runningApp
+ self.isLaunching = false
+ return
+ }
+
+ // Implement a retry mechanism with exponential backoff
+ Task {
+ var retryCount = 0
+ let maxRetries = 3
+ var success = false
+
+ while !success && retryCount < maxRetries {
+ do {
+ // Add a delay between retries with exponential backoff
+ if retryCount > 0 {
+ let delaySeconds = pow(2.0, Double(retryCount - 1))
+ Logger.communicationBridge.info("Retrying launch after \(delaySeconds) seconds (attempt \(retryCount + 1) of \(maxRetries))")
+ try await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
+ }
+
+ // Use a task-based approach for launching with timeout
+ let launchTask = Task { () -> NSRunningApplication? in
+ return await withCheckedContinuation { continuation in
+ NSWorkspace.shared.openApplication(
+ at: appURL,
+ configuration: {
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.createsNewApplicationInstance = false
+ configuration.addsToRecentItems = false
+ configuration.activates = false
+ return configuration
+ }()
+ ) { app, error in
+ if let error = error {
+ continuation.resume(returning: nil)
+ } else {
+ continuation.resume(returning: app)
+ }
+ }
+ }
+ }
+
+ // Set a timeout for the launch operation
+ let timeoutTask = Task {
+ try await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds
+ return
+ }
+
+ // Wait for either the launch or the timeout
+ let app = try await withTaskCancellationHandler {
+ try await launchTask.value ?? nil
+ } onCancel: {
+ launchTask.cancel()
+ }
+
+ // Cancel the timeout task
+ timeoutTask.cancel()
+
+ if let app = app {
+ // Success!
+ self.application = app
+ success = true
+ break
+ } else {
+ // App is nil, retry
+ retryCount += 1
+ Logger.communicationBridge.info("Launch attempt \(retryCount) failed, app is nil")
+ }
+ } catch {
+ retryCount += 1
+ Logger.communicationBridge.error("Error during launch attempt \(retryCount): \(error.localizedDescription)")
+ }
}
-
- self.application = app
+
+ // Double-check we have a valid application
+ if !success && self.application == nil {
+ // After all retries, check once more if the app is running (it might have launched but we missed the callback)
+ if let runningApp = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == appIdentifier
+ }) {
+ Logger.communicationBridge.info("Found running extension service after retries: \(runningApp.processIdentifier)")
+ self.application = runningApp
+ success = true
+ } else {
+ Logger.communicationBridge.info("Failed to launch extension service after \(maxRetries) attempts")
+ }
+ }
+
self.isLaunching = false
}
}
diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index cc07bc2b..e9b7745a 100644
--- a/Copilot for Xcode/App.swift
+++ b/Copilot for Xcode/App.swift
@@ -1,10 +1,11 @@
+import SwiftUI
import Client
import HostApp
import LaunchAgentManager
import SharedUIComponents
-import SwiftUI
import UpdateChecker
import XPCShared
+import HostAppActivator
struct VisualEffect: NSViewRepresentable {
func makeNSView(context: Self.Context) -> NSView { return NSVisualEffectView() }
@@ -12,7 +13,133 @@ struct VisualEffect: NSViewRepresentable {
}
class AppDelegate: NSObject, NSApplicationDelegate {
- func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true }
+ private var permissionAlertShown = false
+
+ // Launch modes supported by the app
+ enum LaunchMode {
+ case chat
+ case settings
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ if #available(macOS 13.0, *) {
+ checkBackgroundPermissions()
+ }
+
+ let launchMode = determineLaunchMode()
+ handleLaunchMode(launchMode)
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+ if #available(macOS 13.0, *) {
+ checkBackgroundPermissions()
+ }
+
+ let launchMode = determineLaunchMode()
+ handleLaunchMode(launchMode)
+ return true
+ }
+
+ // MARK: - Helper Methods
+
+ private func determineLaunchMode() -> LaunchMode {
+ let launchArgs = CommandLine.arguments
+ if launchArgs.contains("--settings") {
+ return .settings
+ } else {
+ return .chat
+ }
+ }
+
+ private func handleLaunchMode(_ mode: LaunchMode) {
+ switch mode {
+ case .settings:
+ openSettings()
+ case .chat:
+ openChat()
+ }
+ }
+
+ private func openSettings() {
+ DispatchQueue.main.async {
+ NSApp.activate(ignoringOtherApps: true)
+ if #available(macOS 14.0, *) {
+ let environment = SettingsEnvironment()
+ environment.open()
+ } else if #available(macOS 13.0, *) {
+ NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+ } else {
+ NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
+ }
+ }
+ }
+
+ private func openChat() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ Task {
+ let service = try? getService()
+ try? await service?.openChat()
+ }
+ }
+ }
+
+ @available(macOS 13.0, *)
+ private func checkBackgroundPermissions() {
+ Task {
+ // Direct check of permission status
+ let launchAgentManager = LaunchAgentManager()
+ let isPermissionGranted = await launchAgentManager.isBackgroundPermissionGranted()
+
+ if !isPermissionGranted {
+ // Only show alert if permission isn't granted
+ DispatchQueue.main.async {
+ if !self.permissionAlertShown {
+ showBackgroundPermissionAlert()
+ self.permissionAlertShown = true
+ }
+ }
+ } else {
+ // Permission is granted, reset flag
+ self.permissionAlertShown = false
+ }
+ }
+ }
+
+ // MARK: - Application Termination
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ // Immediately terminate extension service if it's running
+ if let extensionService = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService"
+ }) {
+ extensionService.terminate()
+ }
+
+ // Start cleanup in background without waiting
+ Task {
+ let quitTask = Task {
+ let service = try? getService()
+ try? await service?.quitService()
+ }
+
+ // Wait just a tiny bit to allow cleanup to start
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ DispatchQueue.main.async {
+ NSApp.reply(toApplicationShouldTerminate: true)
+ }
+ }
+
+ return .terminateLater
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ if let extensionService = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == "\(Bundle.main.bundleIdentifier!).ExtensionService"
+ }) {
+ extensionService.terminate()
+ }
+ }
}
class AppUpdateCheckerDelegate: UpdateCheckerDelegate {
@@ -28,16 +155,40 @@ class AppUpdateCheckerDelegate: UpdateCheckerDelegate {
@main
struct CopilotForXcodeApp: App {
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
+
+ init() {
+ UserDefaults.setupDefaultSettings()
+
+ Task {
+ await hostAppStore
+ .send(.general(.setupLaunchAgentIfNeeded))
+ .finish()
+ }
+
+ DistributedNotificationCenter.default().addObserver(
+ forName: .openSettingsWindowRequest,
+ object: nil,
+ queue: .main
+ ) { _ in
+ DispatchQueue.main.async {
+ NSApp.activate(ignoringOtherApps: true)
+ if #available(macOS 14.0, *) {
+ let environment = SettingsEnvironment()
+ environment.open()
+ } else if #available(macOS 13.0, *) {
+ NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+ } else {
+ NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
+ }
+ }
+ }
+ }
var body: some Scene {
- WindowGroup {
+ Settings {
TabContainer()
.frame(minWidth: 800, minHeight: 600)
.background(VisualEffect().ignoresSafeArea())
- .onAppear {
- UserDefaults.setupDefaultSettings()
- }
- .copilotIntroSheet()
.environment(\.updateChecker, UpdateChecker(
hostBundle: Bundle.main,
checkerDelegate: AppUpdateCheckerDelegate()
diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg
deleted file mode 100644
index 4b00fb12..00000000
--- a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Chat.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg
new file mode 100644
index 00000000..74239992
--- /dev/null
+++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/ChatIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json
index 0368a066..329dae48 100644
--- a/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json
+++ b/Copilot for Xcode/Assets.xcassets/ChatIcon.imageset/Contents.json
@@ -1,12 +1,15 @@
{
"images" : [
{
- "filename" : "Chat.svg",
+ "filename" : "ChatIcon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
}
}
diff --git a/Core/Package.swift b/Core/Package.swift
index 8fbe742c..0567e481 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -178,7 +178,12 @@ let package = Package(
.product(name: "AXHelper", package: "Tool"),
.product(name: "ConversationServiceProvider", package: "Tool"),
.product(name: "GitHubCopilotService", package: "Tool"),
+ .product(name: "Workspace", package: "Tool")
]),
+ .testTarget(
+ name: "ChatServiceTests",
+ dependencies: ["ChatService"]
+ ),
.target(
name: "ConversationTab",
@@ -196,10 +201,6 @@ let package = Package(
.product(name: "Persist", package: "Tool")
]
),
- .testTarget(
- name: "ConversationTabTests",
- dependencies: ["ConversationTab"]
- ),
// MARK: - UI
@@ -218,6 +219,7 @@ let package = Package(
.product(name: "ChatTab", package: "Tool"),
.product(name: "Logger", package: "Tool"),
.product(name: "CustomAsyncAlgorithms", package: "Tool"),
+ .product(name: "HostAppActivator", package: "Tool"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 9d9ef6ef..8088dbb7 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -11,6 +11,8 @@ import Persist
import PersistMiddleware
import ChatTab
import Logger
+import Workspace
+import XcodeInspector
public protocol ChatServiceType {
var memory: ContextAwareAutoManagedChatMemory { get set }
@@ -30,6 +32,8 @@ public final class ChatService: ChatServiceType, ObservableObject {
private let conversationProvider: ConversationServiceProvider?
private let conversationProgressHandler: ConversationProgressHandler
private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
+ // sync all the files in the workspace to watch for changes.
+ private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared
private var cancellables = Set()
private var activeRequestId: String?
private(set) public var conversationId: String?
@@ -47,6 +51,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
subscribeToNotifications()
subscribeToConversationContextRequest()
+ subscribeToWatchedFilesHandler()
}
private func subscribeToNotifications() {
@@ -80,6 +85,14 @@ public final class ChatService: ChatServiceType, ObservableObject {
}
}).store(in: &cancellables)
}
+
+ private func subscribeToWatchedFilesHandler() {
+ self.watchedFilesHandler.onWatchedFiles.sink(receiveValue: { [weak self] (request, completion) in
+ guard let self, request.params!.workspaceUri != "/" else { return }
+ self.startFileChangeWatcher()
+ }).store(in: &cancellables)
+ }
+
public static func service(for chatTabInfo: ChatTabInfo) -> ChatService {
let provider = BuiltinExtensionConversationServiceProvider(
extension: GitHubCopilotExtension.self
@@ -144,8 +157,12 @@ public final class ChatService: ChatServiceType, ObservableObject {
let ignoredSkills: [String] = skillCapabilities.filter {
!supportedSkills.contains($0)
}
+
+ /// replace the `@workspace` to `@project`
+ let newContent = replaceFirstWord(in: content, from: "@workspace", to: "@project")
+
let request = ConversationRequest(workDoneToken: workDoneToken,
- content: content,
+ content: newContent,
workspaceFolder: "",
skills: skillCapabilities,
ignoredSkills: ignoredSkills,
@@ -321,6 +338,7 @@ public final class ChatService: ChatServiceType, ObservableObject {
let id = progress.turnId
var content = ""
var references: [ConversationReference] = []
+ var steps: [ConversationProgressStep] = []
if let reply = progress.reply {
content = reply
@@ -330,13 +348,18 @@ public final class ChatService: ChatServiceType, ObservableObject {
references = progressReferences.toConversationReferences()
}
- if content.isEmpty && references.isEmpty {
+ if let progressSteps = progress.steps, !progressSteps.isEmpty {
+ steps = progressSteps
+ }
+
+ if content.isEmpty && references.isEmpty && steps.isEmpty {
return
}
// create immutable copies
let messageContent = content
let messageReferences = references
+ let messageSteps = steps
Task {
let message = ChatMessage(
@@ -345,7 +368,8 @@ public final class ChatService: ChatServiceType, ObservableObject {
clsTurnID: id,
role: .assistant,
content: messageContent,
- references: messageReferences
+ references: messageReferences,
+ steps: messageSteps
)
// will persist in resetOngoingRequest()
@@ -424,10 +448,24 @@ public final class ChatService: ChatServiceType, ObservableObject {
activeRequestId = nil
isReceivingMessage = false
- // The message of progress report could change rapidly
- // Directly upsert the last chat message of history here
- // Possible repeat upsert, but no harm.
+
Task {
+ // mark running steps to cancelled
+ await mutateHistory({ history in
+ guard !history.isEmpty,
+ let lastIndex = history.indices.last,
+ history[lastIndex].role == .assistant else { return }
+
+ for i in 0.. [ChatAgent]? {
+ guard self.chatAgents == nil else { return self.chatAgents }
+
+ do {
+ if let chatAgents = (try await conversationProvider?.agents()) {
+ self.chatAgents = chatAgents
+ return chatAgents
+ }
+ } catch {
+ // handle error if desired
+ }
+
+ return nil
+ }
}
@@ -531,6 +585,37 @@ extension ChatService {
func fetchAllChatMessagesFromStorage() -> [ChatMessage] {
return ChatMessageStore.getAll(by: self.chatTabInfo.id, metadata: .init(workspacePath: self.chatTabInfo.workspacePath, username: self.chatTabInfo.username))
}
+
+ /// for file change watcher
+ func startFileChangeWatcher() {
+ Task { [weak self] in
+ guard let self else { return }
+ let workspaceURL = URL(fileURLWithPath: self.chatTabInfo.workspacePath)
+ let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL
+ await FileChangeWatcherServicePool.shared.watch(
+ for: workspaceURL
+ ) { fileEvents in
+ Task { [weak self] in
+ guard let self else { return }
+ try? await self.conversationProvider?.notifyDidChangeWatchedFiles(
+ .init(workspaceUri: projectURL.path, changes: fileEvents),
+ workspace: .init(workspaceURL: workspaceURL, projectURL: projectURL)
+ )
+ }
+ }
+ }
+ }
+}
+
+func replaceFirstWord(in content: String, from oldWord: String, to newWord: String) -> String {
+ let pattern = "^\(oldWord)\\b"
+
+ if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
+ let range = NSRange(location: 0, length: content.utf16.count)
+ return regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: newWord)
+ }
+
+ return content
}
extension Array where Element == Reference {
diff --git a/Core/Sources/ChatService/ConversationSkill.swift b/Core/Sources/ChatService/Skills/ConversationSkill.swift
similarity index 67%
rename from Core/Sources/ChatService/ConversationSkill.swift
rename to Core/Sources/ChatService/Skills/ConversationSkill.swift
index df2735e3..d7883b8e 100644
--- a/Core/Sources/ChatService/ConversationSkill.swift
+++ b/Core/Sources/ChatService/Skills/ConversationSkill.swift
@@ -1,8 +1,10 @@
import JSONRPC
import GitHubCopilotService
+public typealias JSONRPCResponseHandler = (AnyJSONRPCResponse) -> Void
+
public protocol ConversationSkill {
var id: String { get }
func applies(params: ConversationContextParams) -> Bool
- func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void)
+ func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler)
}
diff --git a/Core/Sources/ChatService/CurrentEditorSkill.swift b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift
similarity index 95%
rename from Core/Sources/ChatService/CurrentEditorSkill.swift
rename to Core/Sources/ChatService/Skills/CurrentEditorSkill.swift
index 28ab47bc..98b6324f 100644
--- a/Core/Sources/ChatService/CurrentEditorSkill.swift
+++ b/Core/Sources/ChatService/Skills/CurrentEditorSkill.swift
@@ -20,7 +20,7 @@ public class CurrentEditorSkill: ConversationSkill {
return params.skillId == self.id
}
- public func resolveSkill(request: ConversationContextRequest, completion: (AnyJSONRPCResponse) -> Void){
+ public func resolveSkill(request: ConversationContextRequest, completion: JSONRPCResponseHandler){
let uri: String? = self.currentFile.url.absoluteString
completion(
AnyJSONRPCResponse(id: request.id,
diff --git a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
similarity index 97%
rename from Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
rename to Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
index 22f6d3d8..203872db 100644
--- a/Core/Sources/ChatService/ProblemsInActiveDocumentSkill.swift
+++ b/Core/Sources/ChatService/Skills/ProblemsInActiveDocumentSkill.swift
@@ -17,7 +17,7 @@ public class ProblemsInActiveDocumentSkill: ConversationSkill {
return params.skillId == self.id
}
- public func resolveSkill(request: ConversationContextRequest, completion: @escaping (AnyJSONRPCResponse) -> Void) {
+ public func resolveSkill(request: ConversationContextRequest, completion: @escaping JSONRPCResponseHandler) {
Task {
let editor = await XcodeInspector.shared.getFocusedEditorContent()
let result: JSONValue = JSONValue.hash([
diff --git a/Core/Sources/ChatService/Skills/ProjectContextSkill.swift b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift
new file mode 100644
index 00000000..fa564882
--- /dev/null
+++ b/Core/Sources/ChatService/Skills/ProjectContextSkill.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Workspace
+import GitHubCopilotService
+import JSONRPC
+import XcodeInspector
+
+/*
+ * project-context is different from others
+ * 1. The CLS only request this skill once `after initialized` instead of during conversation / turn.
+ * 2. After resolved skill, a file watcher needs to be start for syncing file modification to CLS
+ */
+public class ProjectContextSkill {
+ public static let ID = "project-context"
+
+ public static var resolvedWorkspace: Set = Set()
+
+ public static func isWorkspaceResolved(_ path: String) -> Bool {
+ return ProjectContextSkill.resolvedWorkspace.contains(path)
+ }
+
+ public init() { }
+
+ /*
+ * The request from CLS only contain the projectPath (a initialization paramter for CLS)
+ * whereas to get files for xcode workspace, the workspacePath is needed.
+ */
+ public static func resolveSkill(
+ request: WatchedFilesRequest,
+ workspacePath: String,
+ completion: JSONRPCResponseHandler
+ ) {
+ guard !ProjectContextSkill.isWorkspaceResolved(workspacePath) else {return }
+
+ let params = request.params!
+
+ guard params.workspaceUri != "/" else { return }
+
+ /// build workspace URL
+ let workspaceURL = URL(fileURLWithPath: workspacePath)
+ /// refer to `init` in `Workspace`
+ let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(
+ workspaceURL: workspaceURL,
+ documentURL: nil
+ ) ?? workspaceURL
+
+ /// ignore invalid resolve request
+ guard projectURL.path == params.workspaceUri else { return }
+
+ let files = WorkspaceFile.getWatchedFiles(
+ workspaceURL: workspaceURL,
+ projectURL: projectURL,
+ excludeGitIgnoredFiles: params.excludeGitignoredFiles,
+ excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles
+ )
+
+ let jsonResult = try? JSONEncoder().encode(["files": files])
+ let jsonValue = (try? JSONDecoder().decode(JSONValue.self, from: jsonResult ?? Data())) ?? JSONValue.null
+
+ completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
+
+ ProjectContextSkill.resolvedWorkspace.insert(workspacePath)
+ }
+}
diff --git a/Core/Sources/ConversationTab/Chat.swift b/Core/Sources/ConversationTab/Chat.swift
index 7bb9ad1b..e8c6ce0c 100644
--- a/Core/Sources/ConversationTab/Chat.swift
+++ b/Core/Sources/ConversationTab/Chat.swift
@@ -22,8 +22,9 @@ public struct DisplayedChatMessage: Equatable {
public var followUp: ConversationFollowUp? = nil
public var suggestedTitle: String? = nil
public var errorMessage: String? = nil
+ public var steps: [ConversationProgressStep] = []
- public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil) {
+ public init(id: String, role: Role, text: String, references: [ConversationReference] = [], followUp: ConversationFollowUp? = nil, suggestedTitle: String? = nil, errorMessage: String? = nil, steps: [ConversationProgressStep] = []) {
self.id = id
self.role = role
self.text = text
@@ -31,6 +32,7 @@ public struct DisplayedChatMessage: Equatable {
self.followUp = followUp
self.suggestedTitle = suggestedTitle
self.errorMessage = errorMessage
+ self.steps = steps
}
}
@@ -282,7 +284,8 @@ struct Chat {
},
followUp: message.followUp,
suggestedTitle: message.suggestedTitle,
- errorMessage: message.errorMessage
+ errorMessage: message.errorMessage,
+ steps: message.steps
))
return all
diff --git a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift b/Core/Sources/ConversationTab/ChatDropdownView.swift
similarity index 76%
rename from Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
rename to Core/Sources/ConversationTab/ChatDropdownView.swift
index 15e440c5..0e109584 100644
--- a/Core/Sources/ConversationTab/ChatTemplateDropdownView.swift
+++ b/Core/Sources/ConversationTab/ChatDropdownView.swift
@@ -3,9 +3,27 @@ import AppKit
import SwiftUI
import ComposableArchitecture
-public struct ChatTemplateDropdownView: View {
- @Binding var templates: [ChatTemplate]
- let onSelect: (ChatTemplate) -> Void
+protocol DropDownItem: Equatable {
+ var id: String { get }
+ var displayName: String { get }
+ var displayDescription: String { get }
+}
+
+extension ChatTemplate: DropDownItem {
+ var displayName: String { id }
+ var displayDescription: String { shortDescription }
+}
+
+extension ChatAgent: DropDownItem {
+ var id: String { slug }
+ var displayName: String { slug }
+ var displayDescription: String { description }
+}
+
+struct ChatDropdownView: View {
+ @Binding var items: [T]
+ let prefixSymbol: String
+ let onSelect: (T) -> Void
@State private var selectedIndex = 0
@State private var frameHeight: CGFloat = 0
@State private var localMonitor: Any? = nil
@@ -13,19 +31,19 @@ public struct ChatTemplateDropdownView: View {
public var body: some View {
WithPerceptionTracking {
VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in
+ ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
HStack {
- Text("/" + template.id)
+ Text(prefixSymbol + item.displayName)
.hoverPrimaryForeground(isHovered: selectedIndex == index)
Spacer()
- Text(template.shortDescription)
+ Text(item.displayDescription)
.hoverSecondaryForeground(isHovered: selectedIndex == index)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture {
- onSelect(template)
+ onSelect(item)
}
.hoverBackground(isHovered: selectedIndex == index)
.onHover { isHovered in
@@ -53,7 +71,7 @@ public struct ChatTemplateDropdownView: View {
)
.frame(maxWidth: .infinity)
.offset(y: -1 * frameHeight)
- .onChange(of: templates) { _ in
+ .onChange(of: items) { _ in
selectedIndex = 0
}
.onAppear {
@@ -88,9 +106,9 @@ public struct ChatTemplateDropdownView: View {
}
private func moveSelection(up: Bool) {
- guard !templates.isEmpty else { return }
+ guard !items.isEmpty else { return }
let lowerBound = 0
- let upperBound = templates.count - 1
+ let upperBound = items.count - 1
let newIndex = selectedIndex + (up ? -1 : 1)
selectedIndex = newIndex < lowerBound ? upperBound : (newIndex > upperBound ? lowerBound : newIndex)
}
@@ -104,8 +122,8 @@ public struct ChatTemplateDropdownView: View {
}
private func handleTemplateSelection() {
- if templates.count > 0 && selectedIndex < templates.count {
- onSelect(templates[selectedIndex])
+ if items.count > 0 && selectedIndex < items.count {
+ onSelect(items[selectedIndex])
}
}
}
diff --git a/Core/Sources/ConversationTab/ChatPanel.swift b/Core/Sources/ConversationTab/ChatPanel.swift
index 5983a3a0..655c4570 100644
--- a/Core/Sources/ConversationTab/ChatPanel.swift
+++ b/Core/Sources/ConversationTab/ChatPanel.swift
@@ -10,6 +10,7 @@ import ChatService
import SwiftUIFlowLayout
import XcodeInspector
import ChatTab
+import Workspace
private let r: Double = 8
@@ -336,7 +337,8 @@ struct ChatHistoryItem: View {
references: message.references,
followUp: message.followUp,
errorMessage: message.errorMessage,
- chat: chat
+ chat: chat,
+ steps: message.steps
)
case .system:
FunctionMessage(chat: chat, id: message.id, text: text)
@@ -481,6 +483,8 @@ struct ChatPanelInputArea: View {
}
.buttonStyle(.plain)
}
+
+ enum ShowingType { case template, agent }
struct InputAreaTextEditor: View {
@Perception.Bindable var chat: StoreOf
@@ -489,7 +493,9 @@ struct ChatPanelInputArea: View {
@State private var isFilePickerPresented = false
@State private var allFiles: [FileReference] = []
@State private var filteredTemplates: [ChatTemplate] = []
+ @State private var filteredAgent: [ChatAgent] = []
@State private var showingTemplates = false
+ @State private var dropDownShowingType: ShowingType? = nil
var body: some View {
WithPerceptionTracking {
@@ -510,10 +516,10 @@ struct ChatPanelInputArea: View {
isEditable: true,
maxHeight: 400,
onSubmit: {
- if (!showingTemplates) {
+ if (dropDownShowingType == nil) {
submitChatMessage()
}
- showingTemplates = false
+ dropDownShowingType = nil
},
completions: chatAutoCompletion
)
@@ -523,8 +529,7 @@ struct ChatPanelInputArea: View {
.fixedSize(horizontal: false, vertical: true)
.onChange(of: chat.typedMessage) { newValue in
Task {
- filteredTemplates = await chatTemplateCompletion(text: newValue)
- showingTemplates = !filteredTemplates.isEmpty
+ await onTypedMessageChanged(newValue: newValue)
}
}
}
@@ -584,14 +589,7 @@ struct ChatPanelInputArea: View {
.padding(.top, -4)
}
.overlay(alignment: .top) {
- if showingTemplates {
- ChatTemplateDropdownView(templates: $filteredTemplates) { template in
- chat.typedMessage = "/" + template.id + " "
- if template.id == "releaseNotes" {
- submitChatMessage()
- }
- }
- }
+ dropdownOverlay
}
.onAppear() {
subscribeToActiveDocumentChangeEvent()
@@ -623,7 +621,38 @@ struct ChatPanelInputArea: View {
}
}
}
+
+ private var dropdownOverlay: some View {
+ Group {
+ if dropDownShowingType != nil {
+ if dropDownShowingType == .template {
+ ChatDropdownView(items: $filteredTemplates, prefixSymbol: "/") { template in
+ chat.typedMessage = "/" + template.id + " "
+ if template.id == "releaseNotes" {
+ submitChatMessage()
+ }
+ }
+ } else if dropDownShowingType == .agent {
+ ChatDropdownView(items: $filteredAgent, prefixSymbol: "@") { agent in
+ chat.typedMessage = "@" + agent.id + " "
+ }
+ }
+ }
+ }
+ }
+ func onTypedMessageChanged(newValue: String) async {
+ if newValue.hasPrefix("/") {
+ filteredTemplates = await chatTemplateCompletion(text: newValue)
+ dropDownShowingType = filteredTemplates.isEmpty ? nil : .template
+ } else if newValue.hasPrefix("@") {
+ filteredAgent = await chatAgentCompletion(text: newValue)
+ dropDownShowingType = filteredAgent.isEmpty ? nil : .agent
+ } else {
+ dropDownShowingType = nil
+ }
+ }
+
private var attachedFilesView: some View {
FlowLayout(mode: .scrollable, items: [chat.state.currentEditor] + chat.state.selectedFiles, itemSpacing: 4) { file in
if let select = file {
@@ -691,6 +720,22 @@ struct ChatPanelInputArea: View {
return templates.filter { $0.scopes.contains(.chatPanel) &&
$0.id.hasPrefix(prefix) && !skippedTemplates.contains($0.id)}
}
+
+ func chatAgentCompletion(text: String) async -> [ChatAgent] {
+ guard text.count >= 1 && text.first == "@" else { return [] }
+ let prefix = text.dropFirst()
+ var chatAgents = await SharedChatService.shared.loadChatAgents() ?? []
+
+ if let index = chatAgents.firstIndex(where: { $0.slug == "project" }) {
+ let projectAgent = chatAgents[index]
+ chatAgents[index] = .init(slug: "workspace", name: "workspace", description: "Ask about your workspace", avatarUrl: projectAgent.avatarUrl)
+ }
+
+ /// only enable the @workspace
+ let includedAgents = ["workspace"]
+
+ return chatAgents.filter { $0.slug.hasPrefix(prefix) && includedAgents.contains($0.slug) }
+ }
func chatAutoCompletion(text: String, proposed: [String], range: NSRange) -> [String] {
guard text.count == 1 else { return [] }
@@ -716,11 +761,24 @@ struct ChatPanelInputArea: View {
return result
}
func subscribeToActiveDocumentChangeEvent() {
- XcodeInspector.shared.$activeDocumentURL.receive(on: DispatchQueue.main)
- .sink { newDocURL in
- if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
- let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true)
- chat.send(.setCurrentEditor(currentEditor))
+ Publishers.CombineLatest(
+ XcodeInspector.shared.$latestActiveXcode,
+ XcodeInspector.shared.$activeDocumentURL
+ .removeDuplicates()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { newXcode, newDocURL in
+ // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil
+ if let realtimeURL = newXcode?.realtimeDocumentURL, newDocURL == nil {
+ if supportedFileExtensions.contains(realtimeURL.pathExtension) {
+ let currentEditor = FileReference(url: realtimeURL, isCurrentEditor: true)
+ chat.send(.setCurrentEditor(currentEditor))
+ }
+ } else {
+ if supportedFileExtensions.contains(newDocURL?.pathExtension ?? "") {
+ let currentEditor = FileReference(url: newDocURL!, isCurrentEditor: true)
+ chat.send(.setCurrentEditor(currentEditor))
+ }
}
}
.store(in: &cancellable)
diff --git a/Core/Sources/ConversationTab/ContextUtils.swift b/Core/Sources/ConversationTab/ContextUtils.swift
index 3a7349e8..84517df2 100644
--- a/Core/Sources/ConversationTab/ContextUtils.swift
+++ b/Core/Sources/ConversationTab/ContextUtils.swift
@@ -2,144 +2,18 @@ import ConversationServiceProvider
import XcodeInspector
import Foundation
import Logger
-
-public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml"]
-private let skipPatterns: [String] = [
- ".git",
- ".svn",
- ".hg",
- "CVS",
- ".DS_Store",
- "Thumbs.db",
- "node_modules",
- "bower_components"
-]
+import Workspace
public struct ContextUtils {
- static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool {
- let fileName = url.lastPathComponent
- for pattern in patterns {
- if fnmatch(pattern, fileName, 0) == 0 {
- return true
- }
- }
- return false
- }
public static func getFilesInActiveWorkspace() -> [FileReference] {
guard let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL,
let workspaceRootURL = XcodeInspector.shared.realtimeActiveProjectURL else {
return []
}
-
- return getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL)
- }
-
- static func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] {
- var files: [FileReference] = []
- do {
- let fileManager = FileManager.default
- var subprojects: [URL] = []
- if isXCWorkspace(workspaceURL) {
- subprojects = getSubprojectURLs(in: workspaceURL)
- } else {
- subprojects.append(workspaceRootURL)
- }
- for subproject in subprojects {
- guard FileManager.default.fileExists(atPath: subproject.path) else {
- continue
- }
-
- let enumerator = fileManager.enumerator(
- at: subproject,
- includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
- options: [.skipsHiddenFiles]
- )
-
- while let fileURL = enumerator?.nextObject() as? URL {
- // Skip items matching the specified pattern
- if matchesPatterns(fileURL, patterns: skipPatterns)
- || isXCWorkspace(fileURL) || isXCProject(fileURL) {
- enumerator?.skipDescendants()
- continue
- }
-
- let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey])
- // Handle directories if needed
- if resourceValues.isDirectory == true {
- continue
- }
-
- guard resourceValues.isRegularFile == true else { continue }
- if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false {
- continue
- }
-
- let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "")
- let fileName = fileURL.lastPathComponent
-
- let file = FileReference(url: fileURL,
- relativePath: relativePath,
- fileName: fileName)
- files.append(file)
- }
- }
- } catch {
- Logger.client.error("Failed to get files in workspace: \(error)")
- }
-
+
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL)
+
return files
}
-
- static func isXCWorkspace(_ url: URL) -> Bool {
- return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path)
- }
-
- static func isXCProject(_ url: URL) -> Bool {
- return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path)
- }
-
- static func getSubprojectURLs(in workspaceURL: URL) -> [URL] {
- let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata")
- guard let data = try? Data(contentsOf: workspaceFile) else {
- Logger.client.error("Failed to read workspace file at \(workspaceFile.path)")
- return []
- }
-
- return getSubprojectURLs(workspaceURL: workspaceURL, data: data)
- }
-
- static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] {
- var subprojectURLs: [URL] = []
- do {
- let xml = try XMLDocument(data: data)
- let fileRefs = try xml.nodes(forXPath: "//FileRef")
- for fileRef in fileRefs {
- if let fileRefElement = fileRef as? XMLElement,
- let location = fileRefElement.attribute(forName: "location")?.stringValue {
- var path = ""
- if location.starts(with: "group:") {
- path = location.replacingOccurrences(of: "group:", with: "")
- } else if location.starts(with: "container:") {
- path = location.replacingOccurrences(of: "container:", with: "")
- } else {
- // Skip absolute paths such as absolute:/path/to/project
- continue
- }
-
- if path.hasSuffix(".xcodeproj") {
- path = (path as NSString).deletingLastPathComponent
- }
- let subprojectURL = path.isEmpty ? workspaceURL.deletingLastPathComponent() : workspaceURL.deletingLastPathComponent().appendingPathComponent(path)
- if !subprojectURLs.contains(subprojectURL) {
- subprojectURLs.append(subprojectURL)
- }
- }
- }
- } catch {
- Logger.client.error("Failed to parse workspace file: \(error)")
- }
-
- return subprojectURLs
- }
}
diff --git a/Core/Sources/ConversationTab/ModelPicker.swift b/Core/Sources/ConversationTab/ModelPicker.swift
index 63704b77..97a0555a 100644
--- a/Core/Sources/ConversationTab/ModelPicker.swift
+++ b/Core/Sources/ConversationTab/ModelPicker.swift
@@ -31,12 +31,11 @@ extension AppState {
extension CopilotModelManager {
static func getAvailableChatLLMs() -> [LLMModel] {
let LLMs = CopilotModelManager.getAvailableLLMs()
- let availableModels = LLMs.filter(
+ return LLMs.filter(
{ $0.scopes.contains(.chatPanel) }
).map {
LLMModel(modelName: $0.modelName, modelFamily: $0.modelFamily)
}
- return availableModels.isEmpty ? [defaultModel] : availableModels
}
}
@@ -50,6 +49,7 @@ struct ModelPicker: View {
@State private var selectedModel = defaultModel.modelName
@State private var isHovered = false
@State private var isPressed = false
+ static var lastRefreshModelsTime: Date = .init(timeIntervalSince1970: 0)
init() {
self.updateCurrentModel()
@@ -66,15 +66,23 @@ struct ModelPicker: View {
var body: some View {
WithPerceptionTracking {
Menu(selectedModel) {
- ForEach(models, id: \.self) { option in
+ if models.isEmpty {
Button {
- selectedModel = option.modelName
- AppState.shared.setSelectedModel(option)
+ // No action needed
} label: {
- if selectedModel == option.modelName {
- Text("✓ \(option.modelName)")
- } else {
- Text(" \(option.modelName)")
+ Text("Loading...")
+ }
+ } else {
+ ForEach(models, id: \.self) { option in
+ Button {
+ selectedModel = option.modelName
+ AppState.shared.setSelectedModel(option)
+ } label: {
+ if selectedModel == option.modelName {
+ Text("✓ \(option.modelName)")
+ } else {
+ Text(" \(option.modelName)")
+ }
}
}
}
@@ -108,6 +116,12 @@ struct ModelPicker: View {
@MainActor
func refreshModels() async {
+ let now = Date()
+ if now.timeIntervalSince(Self.lastRefreshModelsTime) < 60 {
+ return
+ }
+
+ Self.lastRefreshModelsTime = now
let copilotModels = await SharedChatService.shared.copilotModels()
if !copilotModels.isEmpty {
CopilotModelManager.updateLLMs(copilotModels)
diff --git a/Core/Sources/ConversationTab/Views/BotMessage.swift b/Core/Sources/ConversationTab/Views/BotMessage.swift
index 8afe2c05..d9d27790 100644
--- a/Core/Sources/ConversationTab/Views/BotMessage.swift
+++ b/Core/Sources/ConversationTab/Views/BotMessage.swift
@@ -16,6 +16,8 @@ struct BotMessage: View {
let followUp: ConversationFollowUp?
let errorMessage: String?
let chat: StoreOf
+ let steps: [ConversationProgressStep]
+
@Environment(\.colorScheme) var colorScheme
@AppStorage(\.chatFontSize) var chatFontSize
@@ -115,6 +117,11 @@ struct BotMessage: View {
)
}
}
+
+ // progress step
+ if steps.count > 0 {
+ ProgressStep(steps: steps)
+ }
ThemedMarkdownText(text: text, chat: chat)
@@ -219,6 +226,13 @@ struct ReferenceList: View {
}
struct BotMessage_Previews: PreviewProvider {
+ static let steps: [ConversationProgressStep] = [
+ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil),
+ .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil),
+ .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil),
+ .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil)
+ ]
+
static var previews: some View {
let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name")
BotMessage(
@@ -236,7 +250,8 @@ struct BotMessage_Previews: PreviewProvider {
), count: 2),
followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"),
errorMessage: "Sorry, an error occurred while generating a response.",
- chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })
+ chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }),
+ steps: steps
)
.padding()
.fixedSize(horizontal: true, vertical: true)
diff --git a/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
new file mode 100644
index 00000000..0fa12715
--- /dev/null
+++ b/Core/Sources/ConversationTab/Views/ConversationProgressStepView.swift
@@ -0,0 +1,74 @@
+import SwiftUI
+import ConversationServiceProvider
+import ComposableArchitecture
+import Combine
+
+struct ProgressStep: View {
+ let steps: [ConversationProgressStep]
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(steps) { StatusItemView(step: $0) }
+ }
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+
+struct StatusItemView: View {
+
+ let step: ConversationProgressStep
+
+ @AppStorage(\.chatFontSize) var chatFontSize
+
+ var statusIcon: some View {
+ Group {
+ switch step.status {
+ case .running:
+ ProgressView()
+ .controlSize(.small)
+ .frame(width: 16, height: 16)
+ .scaleEffect(0.7)
+ case .completed:
+ Image(systemName: "checkmark")
+ .foregroundColor(.green.opacity(0.5))
+ case .failed:
+ Image(systemName: "xmark.circle")
+ .foregroundColor(.red.opacity(0.5))
+ case .cancelled:
+ Image(systemName: "slash.circle")
+ .foregroundColor(.gray.opacity(0.5))
+ }
+ }
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ HStack(spacing: 4) {
+ statusIcon
+ .frame(width: 16, height: 16)
+
+ Text(step.title)
+ .font(.system(size: chatFontSize))
+ .lineLimit(1)
+
+ Spacer()
+ }
+ }
+ }
+}
+
+struct ProgressStep_Preview: PreviewProvider {
+ static let steps: [ConversationProgressStep] = [
+ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil),
+ .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil),
+ .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil),
+ .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil)
+ ]
+ static var previews: some View {
+ ProgressStep(steps: steps)
+ .frame(width: 300, height: 300)
+ }
+}
diff --git a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
index 53396a16..7acff3cd 100644
--- a/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
+++ b/Core/Sources/GitHubCopilotViewModel/GitHubCopilotViewModel.swift
@@ -155,12 +155,12 @@ public class GitHubCopilotViewModel: ObservableObject {
waitingForSignIn = false
self.username = username
self.status = status
+ await Status.shared.updateAuthStatus(.loggedIn, username: username)
+ broadcastStatusChange()
let models = try? await service.models()
if let models = models, !models.isEmpty {
CopilotModelManager.updateLLMs(models)
}
- await Status.shared.updateAuthStatus(.loggedIn, username: username)
- broadcastStatusChange()
} catch let error as GitHubCopilotError {
if case .languageServerError(.timeout) = error {
// TODO figure out how to extend the default timeout on a Chime LSP request
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index a3c66056..f2b2abe8 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -8,16 +8,16 @@ import XPCShared
import Logger
@Reducer
-struct General {
+public struct General {
@ObservableState
- struct State: Equatable {
+ public struct State: Equatable {
var xpcServiceVersion: String?
var isAccessibilityPermissionGranted: ObservedAXStatus = .unknown
var isExtensionPermissionGranted: ExtensionPermissionStatus = .unknown
var isReloading = false
}
- enum Action: Equatable {
+ public enum Action: Equatable {
case appear
case setupLaunchAgentIfNeeded
case openExtensionManager
@@ -35,7 +35,7 @@ struct General {
struct ReloadStatusCancellableId: Hashable {}
- var body: some ReducerOf {
+ public var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
@@ -58,7 +58,7 @@ struct General {
.setupLaunchAgentForTheFirstTimeIfNeeded()
} catch {
Logger.ui.error("Failed to setup launch agent. \(error.localizedDescription)")
- toast(error.localizedDescription, .error)
+ toast("Operation failed: permission denied. This may be due to missing background permissions.", .error)
}
await send(.reloadStatus)
}
@@ -103,7 +103,7 @@ struct General {
} catch let error as XPCCommunicationBridgeError {
Logger.ui.error("Failed to reach communication bridge. \(error.localizedDescription)")
toast(
- "Failed to reach communication bridge. \(error.localizedDescription)",
+ "Unable to connect to the communication bridge. The helper application didn't respond. This may be due to missing background permissions.",
.error
)
await send(.failedReloading)
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index fc03d87b..a48b6a59 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -8,13 +8,13 @@ extension KeyboardShortcuts.Name {
}
@Reducer
-struct HostApp {
+public struct HostApp {
@ObservableState
- struct State: Equatable {
+ public struct State: Equatable {
var general = General.State()
}
- enum Action: Equatable {
+ public enum Action: Equatable {
case appear
case general(General.Action)
}
@@ -25,7 +25,7 @@ struct HostApp {
KeyboardShortcuts.userDefaults = .shared
}
- var body: some ReducerOf {
+ public var body: some ReducerOf {
Scope(state: \.general, action: /Action.general) {
General()
}
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index ee031cb5..ba8a4126 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -1,7 +1,7 @@
import Foundation
import LaunchAgentManager
-extension LaunchAgentManager {
+public extension LaunchAgentManager {
init() {
self.init(
serviceIdentifier: Bundle.main
diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index ae712136..02b4459b 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -7,7 +7,7 @@ import Toast
import UpdateChecker
@MainActor
-let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
+public let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
public struct TabContainer: View {
let store: StoreOf
@@ -234,3 +234,26 @@ struct TabContainer_Toasts_Previews: PreviewProvider {
}
}
+@available(macOS 14.0, *)
+@MainActor
+public struct SettingsEnvironment: View {
+ @Environment(\.openSettings) public var openSettings: OpenSettingsAction
+
+ public init() {}
+
+ public var body: some View {
+ EmptyView().onAppear {
+ openSettings()
+ }
+ }
+
+ public func open() {
+ let controller = NSHostingController(rootView: self)
+ let window = NSWindow(contentViewController: controller)
+ window.orderFront(nil)
+ // Close the temporary window after settings are opened
+ DispatchQueue.main.async {
+ window.close()
+ }
+ }
+}
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index 2dfa2695..c311439d 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -33,6 +33,14 @@ public struct LaunchAgentManager {
await removeObsoleteLaunchAgent()
}
}
+
+ @available(macOS 13.0, *)
+ public func isBackgroundPermissionGranted() async -> Bool {
+ // On macOS 13+, check SMAppService status
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ let status = bridgeLaunchAgent.status
+ return status != .requiresApproval
+ }
public func setupLaunchAgent() async throws {
if #available(macOS 13, *) {
diff --git a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
index 44788b02..acb4b20d 100644
--- a/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
+++ b/Core/Sources/PersistMiddleware/Extensions/ChatMessage+Storage.swift
@@ -13,6 +13,30 @@ extension ChatMessage {
var followUp: ConversationFollowUp?
var suggestedTitle: String?
var errorMessage: String?
+ var steps: [ConversationProgressStep]
+
+ // Custom decoder to provide default value for steps
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ content = try container.decode(String.self, forKey: .content)
+ rating = try container.decode(ConversationRating.self, forKey: .rating)
+ references = try container.decode([ConversationReference].self, forKey: .references)
+ followUp = try container.decodeIfPresent(ConversationFollowUp.self, forKey: .followUp)
+ suggestedTitle = try container.decodeIfPresent(String.self, forKey: .suggestedTitle)
+ errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)
+ steps = try container.decodeIfPresent([ConversationProgressStep].self, forKey: .steps) ?? []
+ }
+
+ // Default memberwise init for encoding
+ init(content: String, rating: ConversationRating, references: [ConversationReference], followUp: ConversationFollowUp?, suggestedTitle: String?, errorMessage: String?, steps: [ConversationProgressStep]?) {
+ self.content = content
+ self.rating = rating
+ self.references = references
+ self.followUp = followUp
+ self.suggestedTitle = suggestedTitle
+ self.errorMessage = errorMessage
+ self.steps = steps ?? []
+ }
}
func toTurnItem() -> TurnItem {
@@ -22,7 +46,8 @@ extension ChatMessage {
references: self.references,
followUp: self.followUp,
suggestedTitle: self.suggestedTitle,
- errorMessage: self.errorMessage
+ errorMessage: self.errorMessage,
+ steps: self.steps
)
// TODO: handle exception
@@ -52,6 +77,7 @@ extension ChatMessage {
suggestedTitle: turnItemData.suggestedTitle,
errorMessage: turnItemData.errorMessage,
rating: turnItemData.rating,
+ steps: turnItemData.steps,
createdAt: turnItem.createdAt,
updatedAt: turnItem.updatedAt
)
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index 28cb7bb6..8072778a 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -94,35 +94,56 @@ public final class Service {
keyBindingManager.start()
Task {
- await XcodeInspector.shared.safe.$activeDocumentURL
- .removeDuplicates()
- .filter { $0 != .init(fileURLWithPath: "/") }
- .compactMap { $0 }
- .sink { [weak self] fileURL in
- Task {
- do {
- let _ = try await self?.workspacePool
- .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
- } catch let error as Workspace.WorkspaceFileError {
- Logger.workspacePool
- .info(error.localizedDescription)
- }
- catch {
- Logger.workspacePool.error(error)
- }
+ await Publishers.CombineLatest(
+ XcodeInspector.shared.safe.$activeDocumentURL
+ .removeDuplicates(),
+ XcodeInspector.shared.safe.$latestActiveXcode
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] documentURL, latestXcode in
+ Task {
+ let fileURL = documentURL ?? latestXcode?.realtimeDocumentURL
+ guard fileURL != nil, fileURL != .init(fileURLWithPath: "/") else {
+ return
+ }
+ do {
+ let _ = try await self?.workspacePool
+ .fetchOrCreateWorkspaceAndFilespace(
+ fileURL: fileURL!
+ )
+ } catch let error as Workspace.WorkspaceFileError {
+ Logger.workspacePool
+ .info(error.localizedDescription)
}
- }.store(in: &cancellable)
+ catch {
+ Logger.workspacePool.error(error)
+ }
+ }
+ }.store(in: &cancellable)
// Combine both workspace and auth status changes into a single stream
- await Publishers.CombineLatest(
+ await Publishers.CombineLatest3(
+ XcodeInspector.shared.safe.$latestActiveXcode,
XcodeInspector.shared.safe.$activeWorkspaceURL
.removeDuplicates(),
StatusObserver.shared.$authStatus
.removeDuplicates()
)
.receive(on: DispatchQueue.main)
- .sink { [weak self] newURL, newStatus in
- self?.onNewActiveWorkspaceURLOrAuthStatus(newURL: newURL, newStatus: newStatus)
+ .sink { [weak self] newXcode, newURL, newStatus in
+ // First check for realtimeWorkspaceURL if activeWorkspaceURL is nil
+ if let realtimeURL = newXcode?.realtimeWorkspaceURL, newURL == nil {
+ self?.onNewActiveWorkspaceURLOrAuthStatus(
+ newURL: realtimeURL,
+ newStatus: newStatus
+ )
+ } else if let newURL = newURL {
+ // Then use activeWorkspaceURL if available
+ self?.onNewActiveWorkspaceURLOrAuthStatus(
+ newURL: newURL,
+ newStatus: newStatus
+ )
+ }
}
.store(in: &cancellable)
}
@@ -137,7 +158,7 @@ public final class Service {
private func getDisplayNameOfXcodeWorkspace(url: URL) -> String {
var name = url.lastPathComponent
- let suffixes = [".xcworkspace", ".xcodeproj"]
+ let suffixes = [".xcworkspace", ".xcodeproj", ".playground"]
for suffix in suffixes {
if name.hasSuffix(suffix) {
name = String(name.dropLast(suffix.count))
@@ -189,13 +210,11 @@ extension Service {
let name = self.getDisplayNameOfXcodeWorkspace(url: workspaceURL)
let path = workspaceURL.path
- // switch workspace and username
- self.guiController.store.send(.switchWorkspace(path: path, name: name, username: username))
-
+ // switch workspace and username and wait for it to complete
+ await self.guiController.store.send(.switchWorkspace(path: path, name: name, username: username)).finish()
// restore if needed
await self.guiController.restore(path: path, name: name, username: username)
-
- // init chat tab if no history tab
- self.guiController.store.send(.initWorkspaceChatTabIfNeeded(path: path, username: username))
+ // init chat tab if no history tab (only after workspace is fully switched and restored)
+ await self.guiController.store.send(.initWorkspaceChatTabIfNeeded(path: path, username: username)).finish()
}
}
diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift
index 0ae21044..9327d8f6 100644
--- a/Core/Sources/Service/XPCService.swift
+++ b/Core/Sources/Service/XPCService.swift
@@ -6,6 +6,7 @@ import Logger
import Preferences
import Status
import XPCShared
+import HostAppActivator
public class XPCService: NSObject, XPCServiceProtocol {
// MARK: - Service
@@ -152,12 +153,23 @@ public class XPCService: NSObject, XPCServiceProtocol {
}
public func openChat(
- editorContent: Data,
- withReply reply: @escaping (Data?, Error?) -> Void
+ withReply reply: @escaping (Error?) -> Void
) {
- let handler = PseudoCommandHandler()
- handler.openChat(forceDetach: true)
- reply(nil, nil)
+ Task {
+ do {
+ // Check if app is already running
+ if let _ = getRunningHostApp() {
+ // App is already running, use the chat service
+ let handler = PseudoCommandHandler()
+ handler.openChat(forceDetach: true)
+ } else {
+ try launchHostAppDefault()
+ }
+ reply(nil)
+ } catch {
+ reply(error)
+ }
+ }
}
public func promptToCode(
@@ -236,4 +248,3 @@ struct NoAccessToAccessibilityAPIError: Error, LocalizedError {
init() {}
}
-
diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index 6282b21c..9cdabd21 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -18,13 +18,14 @@ final class ChatPanelWindow: NSWindow {
minimizeWindow: @escaping () -> Void
) {
self.minimizeWindow = minimizeWindow
+ // Initialize with zero rect initially to prevent flashing
super.init(
contentRect: .zero,
styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView, .closable],
backing: .buffered,
- defer: false
+ defer: true // Use defer to prevent window from appearing immediately
)
-
+
titleVisibility = .hidden
addTitlebarAccessoryViewController({
let controller = NSTitlebarAccessoryViewController()
@@ -41,11 +42,13 @@ final class ChatPanelWindow: NSWindow {
level = widgetLevel(1)
collectionBehavior = [
.fullScreenAuxiliary,
- .transient,
+// .transient,
.fullScreenPrimary,
.fullScreenAllowsTiling,
]
hasShadow = true
+
+ // Set contentView after basic configuration
contentView = NSHostingView(
rootView: ChatWindowView(
store: store,
@@ -56,8 +59,11 @@ final class ChatPanelWindow: NSWindow {
)
.environment(\.chatTabPool, chatTabPool)
)
- setIsVisible(true)
+
+ // Initialize as invisible first
+ alphaValue = 0
isPanelDisplayed = false
+ setIsVisible(true)
storeObserver.observe { [weak self] in
guard let self else { return }
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
index d017d78d..871dd24e 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatLoginView.swift
@@ -10,39 +10,48 @@ struct ChatLoginView: View {
var body: some View {
WithPerceptionTracking {
VStack(spacing: 0){
- VStack(spacing: 20) {
+ VStack(spacing: 24) {
Spacer()
- Image("CopilotLogo")
- .resizable()
- .renderingMode(.template)
- .scaledToFill()
- .frame(width: 60.0, height: 60.0)
- .foregroundColor(.secondary)
+ VStack(spacing: 8) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 60.0, height: 60.0)
+ .foregroundColor(.secondary)
+
+ Text("Welcome to Copilot")
+ .font(.largeTitle)
+ .multilineTextAlignment(.center)
+
+ Text("Your AI-powered coding assistant")
+ .font(.body)
+ .multilineTextAlignment(.center)
+ }
- Text("Welcome to Copilot")
- .font(.system(size: 24))
+ CopilotIntroView()
- Text("Your AI-powered coding assistant\nI use the power of AI to help you:")
- .font(.system(size: 12))
-
- Button("Sign Up for Copilot Free") {
- if let url = URL(string: "https://github.com/features/copilot/plans") {
- openURL(url)
+ VStack(spacing: 8) {
+ Button("Sign Up for Copilot Free") {
+ if let url = URL(string: "https://github.com/features/copilot/plans") {
+ openURL(url)
+ }
}
- }
- .buttonStyle(.borderedProminent)
-
- HStack{
- Text("Already have an account?")
- Button("Sign In") { viewModel.signIn() }
- .buttonStyle(.borderless)
- .foregroundColor(Color("TextLinkForegroundColor"))
+ .buttonStyle(.borderedProminent)
- if viewModel.isRunningAction || viewModel.waitingForSignIn {
- ProgressView()
- .controlSize(.small)
+ HStack{
+ Text("Already have an account?")
+ Button("Sign In") { viewModel.signIn() }
+ .buttonStyle(.borderless)
+ .foregroundColor(Color("TextLinkForegroundColor"))
+
+ if viewModel.isRunningAction || viewModel.waitingForSignIn {
+ ProgressView()
+ .controlSize(.small)
+ }
}
}
+ .padding(.top, 16)
Spacer()
Text("Copilot Free and Copilot Pro may show [public code](https://aka.ms/github-copilot-match-public-code) suggestions and collect telemetry. You can change these [GitHub settings](https://aka.ms/github-copilot-settings) at any time. By continuing, you agree to our [terms](https://github.com/customer-terms/github-copilot-product-specific-terms) and [privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement).")
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
index b62d3b88..299c46cc 100644
--- a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoAXPermissionView.swift
@@ -1,6 +1,5 @@
import SwiftUI
import Perception
-import GitHubCopilotViewModel
import SharedUIComponents
struct ChatNoAXPermissionView: View {
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
new file mode 100644
index 00000000..8d7cbf60
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/ChatNoWorkspaceView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+import Perception
+import SharedUIComponents
+
+struct ChatNoWorkspaceView: View {
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ VStack(alignment: .center, spacing: 32) {
+ Spacer()
+ VStack (alignment: .center, spacing: 8) {
+ Image("CopilotLogo")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 64.0, height: 64.0)
+ .foregroundColor(.secondary)
+
+ Text("No Active Xcode Workspace")
+ .font(.largeTitle)
+ .multilineTextAlignment(.center)
+
+ Text("To use Copilot, open Xcode with an active workspace in focus")
+ .font(.body)
+ .multilineTextAlignment(.center)
+ }
+
+ CopilotIntroView()
+
+ Spacer()
+ }
+ .padding()
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity
+ )
+ }
+ .xcodeStyleFrame(cornerRadius: 10)
+ .ignoresSafeArea(edges: .top)
+ }
+ }
+}
+
+struct ChatNoWorkspace_Previews: PreviewProvider {
+ static var previews: some View {
+ ChatNoWorkspaceView()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
new file mode 100644
index 00000000..5fcd5d2e
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/ChatWindow/CopilotIntroView.swift
@@ -0,0 +1,110 @@
+import SwiftUI
+import Perception
+import SharedUIComponents
+
+struct CopilotIntroView: View {
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .center, spacing: 8) {
+ CopilotIntroItemView(
+ imageName: "CopilotLogo",
+ title: "Inline Code Suggestion",
+ description: "Receive context-aware code suggestions and text completion in Xcode. Press Tab ⇥ to accept."
+ )
+
+ CopilotIntroItemView(
+ systemImage: "option",
+ title: "Full Suggestions",
+ description: "Press Option ⌥ for multi-line suggestions (first line is inline). Use Copilot Chat to refine and explain."
+ )
+
+ CopilotIntroItemView(
+ imageName: "ChatIcon",
+ title: "Chat",
+ description: "Get real-time coding assistance, debug issues, and generate code snippets directly within Xcode."
+ )
+
+ CopilotIntroItemView(
+ imageName: "GitHubMark",
+ title: "GitHub Context",
+ description: "Copilot gives smarter code suggestions with GitHub and project context. Use chat to discuss, debug, and explain your code."
+ )
+ }
+ .padding(0)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+}
+
+struct CopilotIntroItemView: View {
+ let image: Image
+ let title: String
+ let description: String
+
+ public init(imageName: String, title: String, description: String) {
+ self.init(
+ imageObject: Image(imageName),
+ title: title,
+ description: description
+ )
+ }
+
+ public init(systemImage: String, title: String, description: String) {
+ self.init(
+ imageObject: Image(systemName: systemImage),
+ title: title,
+ description: description
+ )
+ }
+
+ public init(imageObject: Image, title: String, description: String) {
+ self.image = imageObject
+ self.title = title
+ self.description = description
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(alignment: .leading, spacing: 0){
+ HStack(alignment: .center, spacing: 8) {
+ image
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFill()
+ .frame(width: 12, height: 12)
+ .foregroundColor(.primary)
+ .padding(.leading, 8)
+
+ Text(title)
+ .font(.body)
+ .kerning(0.096)
+ .multilineTextAlignment(.center)
+ .foregroundColor(.primary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(description)
+ .font(.body)
+ .foregroundColor(.secondary)
+ .padding(.leading, 28)
+ .padding(.top, 4)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .padding(8)
+ .frame(maxWidth: 360, alignment: .top)
+ .background(.primary.opacity(0.1))
+ .cornerRadius(2)
+ .overlay(
+ RoundedRectangle(cornerRadius: 2)
+ .inset(by: 0.5)
+ .stroke(lineWidth: 0)
+ )
+ }
+ }
+}
+
+struct CopilotIntroView_Previews: PreviewProvider {
+ static var previews: some View {
+ CopilotIntroView()
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index 214e56c8..bcd42f48 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -18,14 +18,18 @@ struct ChatWindowView: View {
var body: some View {
WithPerceptionTracking {
- let _ = store.currentChatWorkspace?.selectedTabId // force re-evaluation
+ // Force re-evaluation when workspace state changes
+ let currentWorkspace = store.currentChatWorkspace
+ let selectedTabId = currentWorkspace?.selectedTabId
ZStack {
if statusObserver.observedAXStatus == .notGranted {
ChatNoAXPermissionView()
} else {
switch statusObserver.authStatus.status {
case .loggedIn:
- if isChatHistoryVisible {
+ if currentWorkspace == nil || (currentWorkspace?.tabInfo.isEmpty ?? true) {
+ ChatNoWorkspaceView()
+ } else if isChatHistoryVisible {
ChatHistoryViewWrapper(store: store, isChatHistoryVisible: $isChatHistoryVisible)
} else {
ChatView(store: store, isChatHistoryVisible: $isChatHistoryVisible)
@@ -253,6 +257,8 @@ struct ChatBar: View {
CreateButton(store: store)
ChatHistoryButton(store: store, isChatHistoryVisible: $isChatHistoryVisible)
+
+ SettingsButton(store: store)
}
.padding(.horizontal, 12)
}
@@ -332,11 +338,12 @@ struct ChatBar: View {
Button(action: {
store.send(.createNewTapButtonClicked(kind: nil))
}) {
- Image(systemName: "plus")
+ Image(systemName: "plus.bubble")
}
.buttonStyle(HoverButtonStyle())
.padding(.horizontal, 4)
.help("New Chat")
+ .accessibilityLabel("New Chat")
}
}
}
@@ -350,17 +357,37 @@ struct ChatBar: View {
Button(action: {
isChatHistoryVisible = true
}) {
- Image("HistoryIcon")
- .resizable()
- .scaledToFit()
- .frame(width: 24, height: 24)
+ if #available(macOS 15.0, *) {
+ Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ } else {
+ Image(systemName: "clock.arrow.circlepath")
+ }
}
- .buttonStyle(HoverButtonStyle(padding: -2))
+ .buttonStyle(HoverButtonStyle())
+ .padding(.horizontal, 4)
.help("Show Chats...")
.accessibilityLabel("Show Chats...")
}
}
}
+
+ struct SettingsButton: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.openSettings)
+ }) {
+ Image(systemName: "gearshape")
+ }
+ .buttonStyle(HoverButtonStyle())
+ .padding(.horizontal, 4)
+ .help("Open Settings")
+ .accessibilityLabel("Open Settings")
+ }
+ }
+ }
}
struct ChatTabBarButton: View {
@@ -392,32 +419,42 @@ struct ChatTabContainer: View {
var body: some View {
WithPerceptionTracking {
- let tabInfo = store.currentChatWorkspace?.tabInfo
+ let tabInfoArray = store.currentChatWorkspace?.tabInfo
let selectedTabId = store.currentChatWorkspace?.selectedTabId
?? store.currentChatWorkspace?.tabInfo.first?.id
?? ""
- ZStack {
- if tabInfo == nil || tabInfo!.isEmpty {
- Text("Empty")
- } else {
- ForEach(tabInfo!) { tabInfo in
- if let tab = chatTabPool.getTab(of: tabInfo.id) {
- let isActive = tab.id == selectedTabId
- tab.body
- .opacity(isActive ? 1 : 0)
- .disabled(!isActive)
- .allowsHitTesting(isActive)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- // move it out of window
- .rotationEffect(
- isActive ? .zero : .degrees(90),
- anchor: .topLeading
- )
- } else {
- EmptyView()
- }
- }
+ if let tabInfoArray = tabInfoArray, !tabInfoArray.isEmpty {
+ activeTabsView(
+ tabInfoArray: tabInfoArray,
+ selectedTabId: selectedTabId
+ )
+ } else {
+ // Fallback view for empty state (rarely seen in practice)
+ EmptyView().frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+
+ // View displayed when there are active tabs
+ private func activeTabsView(
+ tabInfoArray: IdentifiedArray,
+ selectedTabId: String
+ ) -> some View {
+ ZStack {
+ ForEach(tabInfoArray) { tabInfo in
+ if let tab = chatTabPool.getTab(of: tabInfo.id) {
+ let isActive = tab.id == selectedTabId
+ tab.body
+ .opacity(isActive ? 1 : 0)
+ .disabled(!isActive)
+ .allowsHitTesting(isActive)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // Inactive tabs are rotated out of view
+ .rotationEffect(
+ isActive ? .zero : .degrees(90),
+ anchor: .topLeading
+ )
}
}
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
index 5735e56e..c1c1424d 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
@@ -6,6 +6,7 @@ import GitHubCopilotService
import SwiftUI
import PersistMiddleware
import ConversationTab
+import HostAppActivator
public enum ChatTabBuilderCollection: Equatable {
case folder(title: String, kinds: [ChatTabKind])
@@ -126,6 +127,7 @@ public struct ChatPanelFeature {
case exitFullScreen
case presentChatPanel(forceDetach: Bool)
case switchWorkspace(String, String, String)
+ case openSettings
// Tabs
case updateChatHistory(ChatWorkspace)
@@ -144,7 +146,6 @@ public struct ChatPanelFeature {
// Chat History
case chatHistoryItemClicked(id: String)
case chatHisotryDeleteButtonClicked(id: String)
-
case chatTab(id: String, action: ChatTabItem.Action)
// persist
@@ -247,6 +248,9 @@ public struct ChatPanelFeature {
state.chatHistory.addWorkspace(ChatWorkspace(id: identifier))
}
return .none
+ case .openSettings:
+ try? launchHostAppSettings()
+ return .none
case let .updateChatHistory(chatWorkspace):
state.chatHistory.updateHistory(chatWorkspace)
return .none
@@ -509,10 +513,13 @@ public struct ChatPanelFeature {
if var existChatWorkspace = state.chatHistory.workspaces[id: chatWorkspace.id] {
if var selectedChatTabInfo = chatWorkspace.tabInfo.first(where: { $0.id == chatWorkspace.selectedTabId }) {
- // cancel selectedChatTabInfo in chat workspace
- selectedChatTabInfo.isSelected = false
+ // Keep the selection state when restoring
+ selectedChatTabInfo.isSelected = true
chatWorkspace.tabInfo[id: selectedChatTabInfo.id] = selectedChatTabInfo
+ // Update the existing workspace's selected tab to match
+ existChatWorkspace.selectedTabId = selectedChatTabInfo.id
+
// merge tab info
existChatWorkspace.tabInfo.append(contentsOf: chatWorkspace.tabInfo)
state.chatHistory.updateHistory(existChatWorkspace)
@@ -565,4 +572,3 @@ extension ChatWorkspace {
return (originalTabInfo, chatTabInfo)
}
}
-
diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
index f1eb94ba..e21f4fb0 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -12,9 +12,9 @@ actor WidgetWindowsController: NSObject {
let userDefaultsObservers = WidgetUserDefaultsObservers()
var xcodeInspector: XcodeInspector { .shared }
- let windows: WidgetWindows
- let store: StoreOf
- let chatTabPool: ChatTabPool
+ nonisolated let windows: WidgetWindows
+ nonisolated let store: StoreOf
+ nonisolated let chatTabPool: ChatTabPool
var currentApplicationProcessIdentifier: pid_t?
@@ -233,6 +233,9 @@ extension WidgetWindowsController {
}
func generateWidgetLocation() -> WidgetLocation? {
+ // Default location when no active application/window
+ let defaultLocation = generateDefaultLocation()
+
if let application = xcodeInspector.latestActiveXcode?.appElement {
if let focusElement = xcodeInspector.focusedEditor?.element,
let parent = focusElement.parent,
@@ -303,11 +306,7 @@ extension WidgetWindowsController {
.first(where: { $0.identifier == "Xcode.WorkspaceWindow" }),
let rect = workspaceWindow.rect
else {
- return WidgetLocation(
- widgetFrame: .zero,
- tabFrame: .zero,
- defaultPanelLocation: .init(frame: .zero, alignPanelTop: false)
- )
+ return defaultLocation
}
window = workspaceWindow
@@ -335,7 +334,23 @@ extension WidgetWindowsController {
)
}
}
- return nil
+ return defaultLocation
+ }
+
+ // Generate a default location when no workspace is opened
+ private func generateDefaultLocation() -> WidgetLocation {
+ let mainScreen = NSScreen.main ?? NSScreen.screens.first!
+ let chatPanelFrame = UpdateLocationStrategy.getChatPanelFrame(mainScreen)
+
+ return WidgetLocation(
+ widgetFrame: .zero,
+ tabFrame: .zero,
+ defaultPanelLocation: .init(
+ frame: chatPanelFrame,
+ alignPanelTop: false
+ ),
+ suggestionPanelLocation: nil
+ )
}
func updatePanelState(_ location: WidgetLocation) async {
@@ -360,8 +375,15 @@ extension WidgetWindowsController {
await MainActor.run {
let state = store.withState { $0 }
let isChatPanelDetached = state.chatPanelState.isDetached
- let hasChat = state.chatPanelState.currentChatWorkspace != nil
- && !state.chatPanelState.currentChatWorkspace!.tabInfo.isEmpty
+ // Check if the user has requested to display the panel, regardless of workspace state
+ let isPanelDisplayed = state.chatPanelState.isPanelDisplayed
+
+ // Keep the chat panel visible even when there's no workspace/tabs if it's explicitly displayed
+ // This ensures the login screen remains visible
+ let shouldShowChatPanel = isPanelDisplayed || (
+ state.chatPanelState.currentChatWorkspace != nil &&
+ !state.chatPanelState.currentChatWorkspace!.tabInfo.isEmpty
+ )
if let activeApp, activeApp.isXcode {
let application = activeApp.appElement
@@ -374,7 +396,7 @@ extension WidgetWindowsController {
windows.toastWindow.alphaValue = noFocus ? 0 : 1
if isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = !hasChat
+ windows.chatPanelWindow.isWindowHidden = !shouldShowChatPanel
} else {
windows.chatPanelWindow.isWindowHidden = noFocus
}
@@ -403,7 +425,7 @@ extension WidgetWindowsController {
}
windows.toastWindow.alphaValue = noFocus ? 0 : 1
if isChatPanelDetached {
- windows.chatPanelWindow.isWindowHidden = !hasChat
+ windows.chatPanelWindow.isWindowHidden = !shouldShowChatPanel
} else {
windows.chatPanelWindow.isWindowHidden = noFocus && !windows
.chatPanelWindow.isKeyWindow
diff --git a/Core/Tests/ChatServiceTests/ChatServiceTests.swift b/Core/Tests/ChatServiceTests/ChatServiceTests.swift
new file mode 100644
index 00000000..f8b5ec26
--- /dev/null
+++ b/Core/Tests/ChatServiceTests/ChatServiceTests.swift
@@ -0,0 +1,22 @@
+import XCTest
+
+@testable import ChatService
+
+final class ReplaceFirstWordTests: XCTestCase {
+ func test_replace_first_word() {
+ let cases: [(String, String)] = [
+ ("", ""),
+ ("workspace 001", "workspace 001"),
+ ("workspace001", "workspace001"),
+ ("@workspace", "@project"),
+ ("@workspace001", "@workspace001"),
+ ("@workspace 001", "@project 001"),
+ ]
+
+ for (input, expected) in cases {
+ let result = replaceFirstWord(in: input, from: "@workspace", to: "@project")
+ XCTAssertEqual(result, expected, "Input: \(input), Expected: \(expected), Result: \(result)")
+ }
+ }
+}
+
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 877cdd92..5e7a287b 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -47,6 +47,21 @@ Most of the logics are implemented inside the package `Core` and `Tool`.
Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details.
+## Local Build
+
+To build the application locally, follow these steps:
+
+1. Navigate to the Script directory and run the build scripts:
+
+ ```sh
+ cd ./Script
+ sh ./uninstall-app.sh # Remove any previous installation
+ rm -rf ../build # Clean the build directory
+ sh ./localbuild-app.sh # Build a fresh copy of the app
+ ```
+
+2. After successful build, the application will be available in the build directory. Copy `GitHub Copilot for Xcode.app` to your Applications folder to test it locally.
+
## SwiftUI Previews
Looks like SwiftUI Previews are not very happy with Objective-C packages when running with app targets. To use previews, please switch schemes to the package product targets.
diff --git a/Docs/background-permission-required.png b/Docs/background-permission-required.png
new file mode 100644
index 00000000..fb35d34b
Binary files /dev/null and b/Docs/background-permission-required.png differ
diff --git a/Docs/connect-comm-bridge-failed.png b/Docs/connect-comm-bridge-failed.png
new file mode 100644
index 00000000..4e8d2587
Binary files /dev/null and b/Docs/connect-comm-bridge-failed.png differ
diff --git a/EditorExtension/OpenChat.swift b/EditorExtension/OpenChat.swift
index fccdc3fe..7ee1d945 100644
--- a/EditorExtension/OpenChat.swift
+++ b/EditorExtension/OpenChat.swift
@@ -10,10 +10,16 @@ class OpenChatCommand: NSObject, XCSourceEditorCommand, CommandType {
with invocation: XCSourceEditorCommandInvocation,
completionHandler: @escaping (Error?) -> Void
) {
- completionHandler(nil)
Task {
- let service = try getService()
- _ = try await service.openChat(editorContent: .init(invocation))
+ do {
+ let service = try getService()
+ try await service.openChat()
+ completionHandler(nil)
+ } catch is CancellationError {
+ completionHandler(nil)
+ } catch {
+ completionHandler(error)
+ }
}
}
}
diff --git a/EditorExtension/OpenSettingsCommand.swift b/EditorExtension/OpenSettingsCommand.swift
index 2350171c..b1262c4b 100644
--- a/EditorExtension/OpenSettingsCommand.swift
+++ b/EditorExtension/OpenSettingsCommand.swift
@@ -7,20 +7,9 @@
import Foundation
import XcodeKit
+import HostAppActivator
-enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError {
- case appNotFound
- case openFailed(exitCode: Int32)
- var errorDescription: String? {
- switch self {
- case .appNotFound:
- return "\(hostAppName()) settings application not found"
- case let .openFailed(exitCode):
- return "Failed to launch \(hostAppName()) settings (exit code \(exitCode))"
- }
- }
-}
class OpenSettingsCommand: NSObject, XCSourceEditorCommand, CommandType {
var name: String { "Open \(hostAppName()) Settings" }
@@ -30,35 +19,18 @@ class OpenSettingsCommand: NSObject, XCSourceEditorCommand, CommandType {
completionHandler: @escaping (Error?) -> Void
) {
Task {
- if let appPath = locateHostBundleURL(url: Bundle.main.bundleURL)?.absoluteString {
- let task = Process()
- task.launchPath = "/usr/bin/open"
- task.arguments = [appPath]
- task.launch()
- task.waitUntilExit()
- if task.terminationStatus == 0 {
- completionHandler(nil)
- } else {
- completionHandler(GitHubCopilotForXcodeSettingsLaunchError.openFailed(exitCode: task.terminationStatus))
- }
- } else {
- completionHandler(GitHubCopilotForXcodeSettingsLaunchError.appNotFound)
- }
- }
- }
-
- func locateHostBundleURL(url: URL) -> URL? {
- var nextURL = url
- while nextURL.path != "/" {
- nextURL = nextURL.deletingLastPathComponent()
- if nextURL.lastPathComponent.hasSuffix(".app") {
- return nextURL
+ do {
+ try launchHostAppSettings()
+ completionHandler(nil)
+ } catch {
+ completionHandler(
+ GitHubCopilotForXcodeSettingsLaunchError
+ .openFailed(
+ errorDescription: error.localizedDescription
+ )
+ )
}
}
- let devAppURL = url
- .deletingLastPathComponent()
- .appendingPathComponent("GitHub Copilot for Xcode Dev.app")
- return devAppURL
}
}
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index f33437c2..02445af5 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -40,7 +40,7 @@ extension AppDelegate {
openCopilotForXcodeItem = NSMenuItem(
title: "Settings",
- action: #selector(openCopilotForXcode),
+ action: #selector(openCopilotForXcodeSettings),
keyEquivalent: ""
)
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index c0a411c3..3dbb386a 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -15,6 +15,7 @@ import XcodeInspector
import XPCShared
import GitHubCopilotViewModel
import StatusBarItemView
+import HostAppActivator
let bundleIdentifierBase = Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
@@ -46,7 +47,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var xpcController: XPCController?
let updateChecker =
UpdateChecker(
- hostBundle: Bundle(url: locateHostBundleURL(url: Bundle.main.bundleURL)),
+ hostBundle: Bundle(url: HostAppURL!),
checkerDelegate: ExtensionUpdateCheckerDelegate()
)
var xpcExtensionService: XPCExtensionService?
@@ -74,6 +75,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
@objc func quit() {
+ if let hostApp = getRunningHostApp() {
+ hostApp.terminate()
+ }
+
+ // Start shutdown process in a task
Task { @MainActor in
await service.prepareForExit()
await xpcController?.quit()
@@ -81,13 +87,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}
- @objc func openCopilotForXcode() {
- let task = Process()
- let appPath = locateHostBundleURL(url: Bundle.main.bundleURL)
- task.launchPath = "/usr/bin/open"
- task.arguments = [appPath.absoluteString]
- task.launch()
- task.waitUntilExit()
+ @objc func openCopilotForXcodeSettings() {
+ try? launchHostAppSettings()
}
@objc func signIntoGitHub() {
@@ -179,10 +180,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.isUserOfService
else { continue }
- if NSWorkspace.shared.runningApplications.contains(where: \.isUserOfService) {
- continue
+
+ // Check if Xcode is running
+ let isXcodeRunning = NSWorkspace.shared.runningApplications.contains {
+ $0.bundleIdentifier == "com.apple.dt.Xcode"
+ }
+
+ if !isXcodeRunning {
+ Logger.client.info("No Xcode instances running, preparing to quit")
+ quit()
}
- quit()
}
}
}
@@ -448,20 +455,6 @@ extension NSRunningApplication {
}
}
-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
-}
-
struct CLSMessage {
let summary: String
let detail: String
diff --git a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json
deleted file mode 100644
index 83896fae..00000000
--- a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/Contents.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "images" : [
- {
- "filename" : "history.svg",
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "properties" : {
- "preserves-vector-representation" : true,
- "template-rendering-intent" : "template"
- }
-}
diff --git a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg b/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg
deleted file mode 100644
index 37462496..00000000
--- a/ExtensionService/Assets.xcassets/HistoryIcon.imageset/history.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift
index 5fdd4445..02656f85 100644
--- a/ExtensionService/XPCController.swift
+++ b/ExtensionService/XPCController.swift
@@ -39,19 +39,38 @@ final class XPCController: XPCServiceDelegate {
func createPingTask() {
pingTask?.cancel()
pingTask = Task { [weak self] in
+ var consecutiveFailures = 0
+ var backoffDelay = 1_000_000_000 // Start with 1 second
+
while !Task.isCancelled {
guard let self else { return }
do {
try await self.bridge.updateServiceEndpoint(self.xpcListener.endpoint)
- try await Task.sleep(nanoseconds: 60_000_000_000)
+ // Reset on success
+ consecutiveFailures = 0
+ backoffDelay = 1_000_000_000
+ try await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds between successful pings
} catch {
- try await Task.sleep(nanoseconds: 1_000_000_000)
+ consecutiveFailures += 1
+ // Log only on 1st, 5th (31 sec), 10th failures, etc. to avoid flooding
+ let shouldLog = consecutiveFailures == 1 || consecutiveFailures % 5 == 0
+
#if DEBUG
// No log, but you should run CommunicationBridge, too.
#else
- Logger.service
- .error("Failed to connect to bridge: \(error.localizedDescription)")
+ if consecutiveFailures == 5 {
+ if #available(macOS 13.0, *) {
+ showBackgroundPermissionAlert()
+ }
+ }
+ if shouldLog {
+ Logger.service.error("Failed to connect to bridge (\(consecutiveFailures) consecutive failures): \(error.localizedDescription)")
+ }
#endif
+
+ // Exponential backoff with a cap
+ backoffDelay = min(backoffDelay * 2, 120_000_000_000) // Cap at 120 seconds
+ try await Task.sleep(nanoseconds: UInt64(backoffDelay))
}
}
}
diff --git a/PackageAssets/es-ES-3-0.bdic b/PackageAssets/es-ES-3-0.bdic
new file mode 100644
index 00000000..5847ac06
Binary files /dev/null and b/PackageAssets/es-ES-3-0.bdic differ
diff --git a/README.md b/README.md
index 0ec39ac1..4497d45d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# GitHub Copilot for Xcode
+🪁# GitHub Copilot for Xcode
[GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer
tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions.
@@ -43,14 +43,12 @@ You can receive auto-complete type suggestions from GitHub Copilot either by sta
-1. A background item will be added to enable Copilot to start when `GitHub Copilot for Xcode` is opened.
+1. A background item will be added to enable the GitHub Copilot for Xcode extension app to connect to the host app. This permission is usually automatically added when first launching the app.
-1. Two permissions are required: `Accessibility` and `Xcode Source Editor
- Extension`. For more on why these permissions are required see
- [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
+1. Three permissions are required for GitHub Copilot for Xcode to function properly: `Background`, `Accessibility`, and `Xcode Source Editor Extension`. For more details on why these permissions are required see [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
The first time the application is run the `Accessibility` permission should be requested:
diff --git a/ReleaseNotes.md b/ReleaseNotes.md
index 5179ef08..e5eee047 100644
--- a/ReleaseNotes.md
+++ b/ReleaseNotes.md
@@ -1,22 +1,17 @@
-### GitHub Copilot for Xcode 0.32.0
+### GitHub Copilot for Xcode 0.33.0
+
**🚀 Highlights**
-* Model picker for selecting LLM model for chat
-* New `/releaseNotes` slash commands for easier access to release information
+* **New Models**: Claude 3.7 Sonnet and GPT 4.5 are now available in the Copilot Chat model selector.
+* **@workspace Context**: Ask questions about your entire codebase by referencing `@workspace` in Copilot Chat.
**💪 Improvements**
-* Auto-switch focus between chat text field and file search bar
-* Support keyboard navigation for file picker in chat context
-* Enhanced instructions for granting accessibility and extension permissions
-* Improved accessibility for chat window
-* Redesigned notification and status bar menu styles
+* Open Copilot Chat with a single click from the Copilot for Xcode app
+* Clearer instructions for granting background permissions
**🛠️ Bug Fixes**
-* Resolved compatibility issues with macOS 12
-* Fixed handling of invalid workspace switch event '/'
-* Corrected chat attachment file picker search path to respect workspace scope
-* Fixed icon display across different themes
-* Added support for previously unsupported file types (.md, .txt) in attachments
-* Adjusted incorrect margins in chat window
+* Resolved false alarms for sign-in and free plan limit notifications
+* Improved app launch performance
+* Fixed workspace and context update issues
diff --git a/Script/export-options-local.plist b/Script/export-options-local.plist
new file mode 100644
index 00000000..9c4fb9f7
--- /dev/null
+++ b/Script/export-options-local.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ method
+ debugging
+ signingStyle
+ automatic
+
+
\ No newline at end of file
diff --git a/Script/localbuild-app.sh b/Script/localbuild-app.sh
new file mode 100644
index 00000000..177c20fe
--- /dev/null
+++ b/Script/localbuild-app.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# Determine paths relative to script location
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+PROJECT_ROOT="$( cd "${SCRIPT_DIR}/.." && pwd )"
+PROJECT_NAME=$(basename "${PROJECT_ROOT}")
+
+# Define build directory
+BUILD_DIR="${PROJECT_ROOT}/build"
+mkdir -p "${BUILD_DIR}"
+
+# Set variables
+APP_NAME="CopiloForXcode"
+SCHEME_NAME="Copilot for Xcode"
+CONFIGURATION="Release"
+ARCHIVE_PATH="${BUILD_DIR}/Archives/${APP_NAME}.xcarchive"
+XCWORKSPACE_PATH="${PROJECT_ROOT}/Copilot for Xcode.xcworkspace"
+EXPORT_PATH="${BUILD_DIR}/Export"
+EXPORT_OPTIONS_PLIST="${PROJECT_ROOT}/Script/export-options-local.plist"
+
+# Clean and build archive
+xcodebuild \
+ -scheme "${SCHEME_NAME}" \
+ -quiet \
+ -archivePath "${ARCHIVE_PATH}" \
+ -configuration "${CONFIGURATION}" \
+ -skipMacroValidation \
+ -showBuildTimingSummary \
+ -disableAutomaticPackageResolution \
+ -workspace "${XCWORKSPACE_PATH}" -verbose -arch arm64 \
+ archive \
+ APP_VERSION='0.0.0'
+
+# Export archive to .app
+xcodebuild -exportArchive \
+ -archivePath "${ARCHIVE_PATH}" \
+ -exportOptionsPlist "${EXPORT_OPTIONS_PLIST}" \
+ -exportPath "${EXPORT_PATH}"
+
+echo "App packaged successfully at ${EXPORT_PATH}/${APP_NAME}.app"
+
+open "${EXPORT_PATH}"
\ No newline at end of file
diff --git a/Server/package-lock.json b/Server/package-lock.json
index d39b02bc..5a58c199 100644
--- a/Server/package-lock.json
+++ b/Server/package-lock.json
@@ -8,13 +8,13 @@
"name": "@github/copilot-xcode",
"version": "0.0.1",
"dependencies": {
- "@github/copilot-language-server": "^1.290.0"
+ "@github/copilot-language-server": "^1.298.0"
}
},
"node_modules/@github/copilot-language-server": {
- "version": "1.290.0",
- "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.290.0.tgz",
- "integrity": "sha512-Fdjo7az1LEGBRrvGRrRM2MR9bbNXQtSzqXchCT32Yjz0LR+clX2U1QTFYC6oFudNDwsuk6FviprXxH3RILhxvw==",
+ "version": "1.298.0",
+ "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.298.0.tgz",
+ "integrity": "sha512-3IEIHWs5K/Hiqg+af7ygAiQ6xz0bwsZ4swlpMLj7Lc1h7XQDF7RI148GbfxXuDPrGlf7KZmjfhvb6+uJ73ClTA==",
"license": "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features",
"dependencies": {
"vscode-languageserver-protocol": "^3.17.5"
diff --git a/Server/package.json b/Server/package.json
index f13aef8b..cda3ecda 100644
--- a/Server/package.json
+++ b/Server/package.json
@@ -4,6 +4,6 @@
"description": "Package for downloading @github/copilot-language-server",
"private": true,
"dependencies": {
- "@github/copilot-language-server": "^1.290.0"
+ "@github/copilot-language-server": "^1.298.0"
}
}
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
index 98fdca78..4c179941 100644
--- a/TROUBLESHOOTING.md
+++ b/TROUBLESHOOTING.md
@@ -5,14 +5,15 @@ common issues:
1. Check for updates and restart Xcode. Ensure that Copilot for Xcode has the
[latest release](https://github.com/github/CopilotForXcode/releases/latest)
- by click `Check for Updates` in the settings or under the status menu. After
+ by clicking `Check for Updates` in the settings or under the status menu. After
updating, restart Xcode.
-2. Ensure that the Copilot for Xcode extension is enabled. Open Xcode and go to
- the top menu bar and open the `Editor` menu. If there is no `GitHub Copilot`
- menu is under `Editor` then [extension permission](#extension-permission)
- needs to be enabled. If the `GitHub Copilot` menu is shown but grayed out,
- then Xcode needs to be restarted to enable the extension.
+2. Ensure that all required permissions are granted. GitHub Copilot for Xcode app requires these permissions to function properly:
+ - [Extension Permission](#extension-permission) - Allows GitHub Copilot to integrate with Xcode
+ - [Accessibility Permission](#accessibility-permission) - Enables real-time code suggestions
+ - [Background Permission](#background-permission) - Allows extension to connect with host app
+
+ Please note that GitHub Copilot for Xcode may not work properly if any necessary permissions are missing.
3. Need more help? If these steps don't resolve the issue, please [open an
issue](https://github.com/github/CopilotForXcode/issues/new/choose). Make
@@ -40,7 +41,7 @@ real-time updates from the active Xcode editor. [The XcodeKit
API](https://developer.apple.com/documentation/xcodekit)
enabled by the Xcode Source Editor extension permission only provides
information when manually triggered by the user. In order to generate
-suggestions as you type, the accessibility permission is used read the
+suggestions as you type, the accessibility permission is used to read the
Xcode editor content in real-time.
The accessibility permission is also used to accept suggestions when `tab` is
@@ -53,6 +54,32 @@ but you can audit the usage in this repository: search for `CGEvent` and `AX`*.
Enable in System Settings under `Privacy & Security` > `Accessibility` >
`GitHub Copilot for Xcode Extension` and turn on the toggle.
+## Background Permission
+
+GitHub Copilot for Xcode requires background permission to connect with the host app. This permission ensures proper communication between the components of GitHub Copilot for Xcode, which is essential for its functionality in Xcode.
+
+
+
+
+
+
+This permission is typically granted automatically when you first launch GitHub Copilot for Xcode. However, if you encounter connection issues, alerts, or errors as follows:
+
+
+
+
+
+
+Please ensure that this permission is enabled. You can manually navigate to the background permission setting based on your macOS version:
+
+| macOS | Location |
+| :--- | :--- |
+| 15 | System Settings > General > Login Items & Extensions > Allow in the Background |
+| 13 & 14 | System Settings > General > Login Items > Allow in the Background |
+
+Ensure that "GitHub Copilot for Xcode" is enabled in the list of allowed background items. Without this permission, the extension may not be able to properly communicate with the host app, which can result in inconsistent behavior or reduced functionality.
+
+
## Logs
Logs can be found in `~/Library/Logs/GitHubCopilot/` the most recent log file
diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan
index 32348cce..091e7fe5 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -92,11 +92,22 @@
"name" : "WorkspaceSuggestionServiceTests"
}
},
+ {
+ "skippedTests" : [
+ "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsAddedProjects()",
+ "FileChangeWatcherServiceTests\/testProjectMonitoringDetectsRemovedProjects()"
+ ],
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "WorkspaceTests",
+ "name" : "WorkspaceTests"
+ }
+ },
{
"target" : {
"containerPath" : "container:Core",
- "identifier" : "ConversationTabTests",
- "name" : "ConversationTabTests"
+ "identifier" : "ChatServiceTests",
+ "name" : "ChatServiceTests"
}
}
],
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 541eba43..59791c36 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -61,7 +61,8 @@ let package = Package(
.library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]),
.library(name: "AXHelper", targets: ["AXHelper"]),
.library(name: "Cache", targets: ["Cache"]),
- .library(name: "StatusBarItemView", targets: ["StatusBarItemView"])
+ .library(name: "StatusBarItemView", targets: ["StatusBarItemView"]),
+ .library(name: "HostAppActivator", targets: ["HostAppActivator"]),
],
dependencies: [
// TODO: Update LanguageClient some day.
@@ -83,7 +84,7 @@ let package = Package(
targets: [
// MARK: - Helpers
- .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status"]),
+ .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger", "Status", "HostAppActivator"]),
.target(name: "Configs"),
@@ -121,6 +122,13 @@ let package = Package(
),
.target(name: "ActiveApplicationMonitor"),
+
+ .target(
+ name: "HostAppActivator",
+ dependencies: [
+ "Logger",
+ ]
+ ),
.target(
name: "SuggestionBasic",
@@ -199,8 +207,10 @@ let package = Package(
"Logger",
"Preferences",
"XcodeInspector",
+ "ConversationServiceProvider"
]
),
+ .testTarget(name: "WorkspaceTests", dependencies: ["Workspace"]),
.target(
name: "WorkspaceSuggestionService",
@@ -262,6 +272,7 @@ let package = Package(
.target(name: "ConversationServiceProvider", dependencies: [
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
+ .product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
]),
.target(name: "TelemetryServiceProvider", dependencies: [
diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
index f59f5eeb..aca37267 100644
--- a/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
+++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionConversationServiceProvider.swift
@@ -123,4 +123,26 @@ public final class BuiltinExtensionConversationServiceProvider<
return (try? await conversationService.models(workspace: workspaceInfo))
}
+
+ public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return
+ }
+
+ try? await conversationService.notifyDidChangeWatchedFiles(event, workspace: workspace)
+ }
+
+ public func agents() async throws -> [ChatAgent]? {
+ guard let conversationService else {
+ Logger.service.error("Builtin chat service not found.")
+ return nil
+ }
+ guard let workspaceInfo = await activeWorkspace() else {
+ Logger.service.error("Could not get active workspace info")
+ return nil
+ }
+
+ return (try? await conversationService.agents(workspace: workspaceInfo))
+ }
}
diff --git a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
index e2e3a6a1..097e3d6a 100644
--- a/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
+++ b/Tool/Sources/ChatAPIService/Memory/ChatMemory.swift
@@ -53,5 +53,20 @@ extension ChatMessage {
if let errorMessage = message.errorMessage {
self.errorMessage = (self.errorMessage ?? "") + errorMessage
}
+
+ // merge steps
+ if !message.steps.isEmpty {
+ var mergedSteps = self.steps
+
+ for newStep in message.steps {
+ if let index = mergedSteps.firstIndex(where: { $0.id == newStep.id }) {
+ mergedSteps[index] = newStep
+ } else {
+ mergedSteps.append(newStep)
+ }
+ }
+
+ self.steps = mergedSteps
+ }
}
}
diff --git a/Tool/Sources/ChatAPIService/Models.swift b/Tool/Sources/ChatAPIService/Models.swift
index e44becf5..7e9d7bd6 100644
--- a/Tool/Sources/ChatAPIService/Models.swift
+++ b/Tool/Sources/ChatAPIService/Models.swift
@@ -101,6 +101,9 @@ public struct ChatMessage: Equatable, Codable {
/// The error occurred during responding chat in server
public var errorMessage: String?
+ /// The steps of conversation progress
+ public var steps: [ConversationProgressStep]
+
/// The timestamp of the message.
public var createdAt: Date
public var updatedAt: Date
@@ -116,6 +119,7 @@ public struct ChatMessage: Equatable, Codable {
suggestedTitle: String? = nil,
errorMessage: String? = nil,
rating: ConversationRating = .unrated,
+ steps: [ConversationProgressStep] = [],
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
@@ -129,6 +133,7 @@ public struct ChatMessage: Equatable, Codable {
self.suggestedTitle = suggestedTitle
self.errorMessage = errorMessage
self.rating = rating
+ self.steps = steps
let now = Date.now
self.createdAt = createdAt ?? now
diff --git a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
index 11accda2..2706c5ef 100644
--- a/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
+++ b/Tool/Sources/ConversationServiceProvider/ConversationServiceProvider.swift
@@ -1,6 +1,7 @@
import CopilotForXcodeKit
import Foundation
import CodableWrappers
+import LanguageServerProtocol
public protocol ConversationServiceType {
func createConversation(_ request: ConversationRequest, workspace: WorkspaceInfo) async throws
@@ -10,6 +11,8 @@ public protocol ConversationServiceType {
func copyCode(request: CopyCodeRequest, workspace: WorkspaceInfo) async throws
func templates(workspace: WorkspaceInfo) async throws -> [ChatTemplate]?
func models(workspace: WorkspaceInfo) async throws -> [CopilotModel]?
+ func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws
+ func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]?
}
public protocol ConversationServiceProvider {
@@ -20,6 +23,8 @@ public protocol ConversationServiceProvider {
func copyCode(_ request: CopyCodeRequest) async throws
func templates() async throws -> [ChatTemplate]?
func models() async throws -> [CopilotModel]?
+ func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws
+ func agents() async throws -> [ChatAgent]?
}
public struct FileReference: Hashable, Codable, Equatable {
@@ -152,3 +157,37 @@ public struct ConversationFollowUp: Codable, Equatable {
self.type = type
}
}
+
+public struct ConversationProgressStep: Codable, Equatable, Identifiable {
+ public enum StepStatus: String, Codable {
+ case running, completed, failed, cancelled
+ }
+
+ public struct StepError: Codable, Equatable {
+ public let message: String
+ }
+
+ public let id: String
+ public let title: String
+ public let description: String?
+ public var status: StepStatus
+ public let error: StepError?
+
+ public init(id: String, title: String, description: String?, status: StepStatus, error: StepError?) {
+ self.id = id
+ self.title = title
+ self.description = description
+ self.status = status
+ self.error = error
+ }
+}
+
+public struct DidChangeWatchedFilesEvent: Codable {
+ public var workspaceUri: String
+ public var changes: [FileEvent]
+
+ public init(workspaceUri: String, changes: [FileEvent]) {
+ self.workspaceUri = workspaceUri
+ self.changes = changes
+ }
+}
diff --git a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
index f6bad68a..b73265b3 100644
--- a/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
+++ b/Tool/Sources/ConversationServiceProvider/LSPTypes.swift
@@ -43,3 +43,18 @@ public struct CopilotModelPolicy: Codable, Equatable {
public let state: String
public let terms: String
}
+
+// MARK: Conversation Agents
+public struct ChatAgent: Codable, Equatable {
+ public let slug: String
+ public let name: String
+ public let description: String
+ public let avatarUrl: String?
+
+ public init(slug: String, name: String, description: String, avatarUrl: String?) {
+ self.slug = slug
+ self.name = name
+ self.description = description
+ self.avatarUrl = avatarUrl
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift
new file mode 100644
index 00000000..1d3b45b5
--- /dev/null
+++ b/Tool/Sources/GitHubCopilotService/Conversation/WatchedFilesHandler.swift
@@ -0,0 +1,55 @@
+import JSONRPC
+import Combine
+import Workspace
+import XcodeInspector
+import Foundation
+
+public protocol WatchedFilesHandler {
+ var onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> { get }
+ func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?)
+}
+
+public final class WatchedFilesHandlerImpl: WatchedFilesHandler {
+ public static let shared = WatchedFilesHandlerImpl()
+
+ public let onWatchedFiles: PassthroughSubject<(WatchedFilesRequest, (AnyJSONRPCResponse) -> Void), Never> = .init()
+
+ public func handleWatchedFiles(_ request: WatchedFilesRequest, workspaceURL: URL, completion: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) {
+ guard let params = request.params, params.workspaceUri != "/" else { return }
+
+ let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) ?? workspaceURL
+
+ let files = WorkspaceFile.getWatchedFiles(
+ workspaceURL: workspaceURL,
+ projectURL: projectURL,
+ excludeGitIgnoredFiles: params.excludeGitignoredFiles,
+ excludeIDEIgnoredFiles: params.excludeIDEIgnoredFiles
+ )
+
+ let batchSize = BatchingFileChangeWatcher.maxEventPublishSize
+ /// only `batchSize`(100) files to complete this event for setup watching workspace in CLS side
+ let jsonResult: JSONValue = .array(files.prefix(batchSize).map { .string($0) })
+ let jsonValue: JSONValue = .hash(["files": jsonResult])
+
+ completion(AnyJSONRPCResponse(id: request.id, result: jsonValue))
+
+ Task {
+ if files.count > batchSize {
+ for startIndex in stride(from: batchSize, to: files.count, by: batchSize) {
+ let endIndex = min(startIndex + batchSize, files.count)
+ let batch = Array(files[startIndex.. GitHubCopilotService {
- let newService = try GitHubCopilotService(projectRootURL: projectRootURL)
+ let newService = try GitHubCopilotService(projectRootURL: projectRootURL, workspaceURL: workspaceURL)
Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
finishLaunchingService()
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
index bc5c2a18..416e7379 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/CopilotLocalProcessServer.swift
@@ -149,6 +149,19 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
server.sendNotification(notif, completionHandler: completionHandler)
}
+
+ /// send copilot specific notification
+ public func sendCopilotNotification(
+ _ notif: CopilotClientNotification,
+ completionHandler: @escaping (ServerError?) -> Void
+ ) {
+ guard let server = wrappedServer, process.isRunning else {
+ completionHandler(.serverUnavailable)
+ return
+ }
+
+ server.sendCopilotNotification(notif, completionHandler: completionHandler)
+ }
/// Cancel ongoing completion requests.
public func cancelOngoingTasks() async {
@@ -195,6 +208,10 @@ extension CopilotLocalProcessServer: LanguageServerProtocol.Server {
}
}
+protocol CopilotNotificationJSONRPCLanguageServer {
+ func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void)
+}
+
final class CustomJSONRPCLanguageServer: Server {
let internalServer: JSONRPCLanguageServer
@@ -294,7 +311,7 @@ extension CustomJSONRPCLanguageServer {
.updateCLSStatus(
payload.kind.clsStatus,
busy: payload.busy,
- message: payload.message
+ message: payload.message ?? ""
)
}
}
@@ -329,7 +346,12 @@ extension CustomJSONRPCLanguageServer {
callback: @escaping (AnyJSONRPCResponse) -> Void
) -> Bool {
serverRequestPublisher.send((request: request, callback: callback))
- return false
+ switch request.method {
+ case "copilot/watchedFiles":
+ return true
+ default:
+ return false
+ }
}
}
@@ -342,3 +364,44 @@ extension CustomJSONRPCLanguageServer {
}
}
+// MARK: - Copilot custom notification
+
+public struct CopilotDidChangeWatchedFilesParams: Codable, Hashable {
+ /// The CLS need an additional paramter `workspaceUri` for "workspace/didChangeWatchedFiles" event
+ public var workspaceUri: String
+ public var changes: [FileEvent]
+
+ public init(workspaceUri: String, changes: [FileEvent]) {
+ self.workspaceUri = workspaceUri
+ self.changes = changes
+ }
+}
+
+public enum CopilotClientNotification {
+ public enum Method: String {
+ case workspaceDidChangeWatchedFiles = "workspace/didChangeWatchedFiles"
+ }
+
+ case copilotDidChangeWatchedFiles(CopilotDidChangeWatchedFilesParams)
+
+ public var method: Method {
+ switch self {
+ case .copilotDidChangeWatchedFiles:
+ return .workspaceDidChangeWatchedFiles
+ }
+ }
+}
+
+extension CustomJSONRPCLanguageServer: CopilotNotificationJSONRPCLanguageServer {
+ public func sendCopilotNotification(_ notif: CopilotClientNotification, completionHandler: @escaping (ServerError?) -> Void) {
+ let method = notif.method.rawValue
+
+ switch notif {
+ case .copilotDidChangeWatchedFiles(let params):
+ // the protocolTransport is not exposed by LSP Server, need to use it directly
+ protocolTransport.sendNotification(params, method: method) { error in
+ completionHandler(error.map({ .unableToSendNotification($0) }))
+ }
+ }
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
index c85a781f..754c0d73 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest+Conversation.swift
@@ -68,6 +68,7 @@ public struct ConversationProgressReport: BaseConversationProgress {
public let turnId: String
public let reply: String?
public let references: [Reference]?
+ public let steps: [ConversationProgressStep]?
}
public struct ConversationProgressEnd: BaseConversationProgress {
@@ -136,6 +137,7 @@ struct TurnCreateParams: Codable {
var ignoredSkills: [String]?
var references: [Reference]?
var model: String?
+ var workspaceFolder: String?
}
// MARK: Copy
@@ -160,3 +162,14 @@ public struct ConversationContextParams: Codable {
}
public typealias ConversationContextRequest = JSONRPCRequest
+
+
+// MARK: Watched Files
+
+public struct WatchedFilesParams: Codable {
+ public var workspaceUri: String
+ public var excludeGitignoredFiles: Bool
+ public var excludeIDEIgnoredFiles: Bool
+}
+
+public typealias WatchedFilesRequest = JSONRPCRequest
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
index 4f66a4c3..8eddd140 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotRequest.swift
@@ -356,6 +356,16 @@ enum GitHubCopilotRequest {
.custom("copilot/models", .hash([:]))
}
}
+
+ // MARK: - Conversation Agents
+
+ struct GetAgents: GitHubCopilotRequestType {
+ typealias Response = Array
+
+ var request: ClientRequest {
+ .custom("conversation/agents", .hash([:]))
+ }
+ }
// MARK: Copy code
@@ -413,7 +423,7 @@ public enum GitHubCopilotNotification {
public var kind: StatusKind
public var busy: Bool
- public var message: String
+ public var message: String?
public static func decode(fromParams params: JSONValue?) -> StatusNotification? {
try? JSONDecoder().decode(Self.self, from: (try? JSONEncoder().encode(params)) ?? Data())
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
index 0b679f57..20da770e 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift
@@ -66,7 +66,8 @@ public protocol GitHubCopilotConversationServiceType {
doc: Doc?,
ignoredSkills: [String]?,
references: [FileReference],
- model: String?) async throws
+ model: String?,
+ workspaceFolder: String?) async throws
func rateConversation(turnId: String, rating: ConversationRating) async throws
func copyCode(turnId: String, codeBlockIndex: Int, copyType: CopyKind, copiedCharacters: Int, totalCharacters: Int, copiedText: String) async throws
func cancelProgress(token: String) async
@@ -79,6 +80,10 @@ protocol GitHubCopilotLSP {
func sendNotification(_ notif: ClientNotification) async throws
}
+protocol GitHubCopilotLSPNotification {
+ func sendCopilotNotification(_ notif: CopilotClientNotification) async throws
+}
+
public enum GitHubCopilotError: Error, LocalizedError {
case languageServerNotInstalled
case languageServerError(ServerError)
@@ -142,7 +147,7 @@ public class GitHubCopilotBaseService {
sessionId = UUID().uuidString
}
- init(projectRootURL: URL) throws {
+ init(projectRootURL: URL, workspaceURL: URL = URL(fileURLWithPath: "/")) throws {
self.projectRootURL = projectRootURL
self.sessionId = UUID().uuidString
let (server, localServer) = try {
@@ -156,7 +161,10 @@ public class GitHubCopilotBaseService {
let xcodeVersion = JSONValue(
stringLiteral: SystemUtils.xcodeVersion ?? ""
)
-
+ let watchedFiles = JSONValue(
+ booleanLiteral: projectRootURL.path == "/" ? false : true
+ )
+
#if DEBUG
// Use local language server if set and available
if let languageServerPath = Bundle.main.infoDictionary?["LANGUAGE_SERVER_PATH"] as? String {
@@ -199,7 +207,18 @@ public class GitHubCopilotBaseService {
server.defaultTimeout = 60
server.initializeParamsProvider = {
let capabilities = ClientCapabilities(
- workspace: nil,
+ workspace: .init(
+ applyEdit: false,
+ workspaceEdit: nil,
+ didChangeConfiguration: nil,
+ didChangeWatchedFiles: nil,
+ symbol: nil,
+ executeCommand: nil,
+ /// enable for "watchedFiles capability", set others to default value
+ workspaceFolders: true,
+ configuration: nil,
+ semanticTokens: nil
+ ),
textDocument: nil,
window: nil,
general: nil,
@@ -219,6 +238,10 @@ public class GitHubCopilotBaseService {
"editorPluginInfo": [
"name": "copilot-xcode",
"version": versionNumber,
+ ],
+ "copilotCapabilities": [
+ /// The editor has support for watching files over LSP
+ "watchedFiles": watchedFiles,
]
],
capabilities: capabilities,
@@ -325,14 +348,14 @@ public final class GitHubCopilotService:
super.init(designatedServer: designatedServer)
}
- override public init(projectRootURL: URL = URL(fileURLWithPath: "/")) throws {
+ override public init(projectRootURL: URL = URL(fileURLWithPath: "/"), workspaceURL: URL = URL(fileURLWithPath: "/")) throws {
do {
- try super.init(projectRootURL: projectRootURL)
+ try super.init(projectRootURL: projectRootURL, workspaceURL: workspaceURL)
localProcessServer?.notificationPublisher.sink(receiveValue: { [weak self] notification in
self?.serverNotificationHandler.handleNotification(notification)
}).store(in: &cancellables)
localProcessServer?.serverRequestPublisher.sink(receiveValue: { [weak self] (request, callback) in
- self?.serverRequestHandler.handleRequest(request, callback: callback)
+ self?.serverRequestHandler.handleRequest(request, workspaceURL: workspaceURL, callback: callback, service: self)
}).store(in: &cancellables)
updateStatusInBackground()
@@ -366,7 +389,7 @@ public final class GitHubCopilotService:
do {
let completions = try await self
.sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init(
- textDocument: .init(uri: fileURL.path, version: 1),
+ textDocument: .init(uri: fileURL.absoluteString, version: 1),
position: cursorPosition,
formattingOptions: .init(
tabSize: tabSize,
@@ -501,7 +524,7 @@ public final class GitHubCopilotService:
}
@GitHubCopilotSuggestionActor
- public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?, ignoredSkills: [String]?, references: [FileReference], model: String?) async throws {
+ public func createTurn(_ message: String, workDoneToken: String, conversationId: String, doc: Doc?, ignoredSkills: [String]?, references: [FileReference], model: String?, workspaceFolder: String?) async throws {
do {
let params = TurnCreateParams(workDoneToken: workDoneToken,
conversationId: conversationId,
@@ -516,7 +539,8 @@ public final class GitHubCopilotService:
openedAt: nil,
activeAt: nil)
},
- model: model)
+ model: model,
+ workspaceFolder: workspaceFolder)
_ = try await sendRequest(
GitHubCopilotRequest.CreateTurn(params: params)
@@ -550,6 +574,18 @@ public final class GitHubCopilotService:
throw error
}
}
+
+ @GitHubCopilotSuggestionActor
+ public func agents() async throws -> [ChatAgent] {
+ do {
+ let response = try await sendRequest(
+ GitHubCopilotRequest.GetAgents()
+ )
+ return response
+ } catch {
+ throw error
+ }
+ }
@GitHubCopilotSuggestionActor
public func rateConversation(turnId: String, rating: ConversationRating) async throws {
@@ -667,6 +703,12 @@ public final class GitHubCopilotService:
// Logger.service.debug("Close \(uri)")
try await server.sendNotification(.didCloseTextDocument(.init(uri: uri)))
}
+
+ @GitHubCopilotSuggestionActor
+ public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent) async throws {
+// Logger.service.debug("notifyDidChangeWatchedFiles \(event)")
+ try await sendCopilotNotification(.copilotDidChangeWatchedFiles(.init(workspaceUri: event.workspaceUri, changes: event.changes)))
+ }
@GitHubCopilotSuggestionActor
public func terminate() async {
@@ -695,13 +737,13 @@ public final class GitHubCopilotService:
private func updateServiceAuthStatus(_ status: GitHubCopilotRequest.CheckStatus.Response) async {
Logger.gitHubCopilot.info("check status response: \(status)")
if status.status == .ok || status.status == .maybeOk {
+ await Status.shared.updateAuthStatus(.loggedIn, username: status.user)
if !CopilotModelManager.hasLLMs() {
let models = try? await models()
if let models = models, !models.isEmpty {
CopilotModelManager.updateLLMs(models)
}
}
- await Status.shared.updateAuthStatus(.loggedIn, username: status.user)
await unwatchAuthStatus()
} else if status.status == .notAuthorized {
await Status.shared
@@ -904,3 +946,16 @@ extension InitializingServer: GitHubCopilotLSP {
}
}
+extension GitHubCopilotService {
+ func sendCopilotNotification(_ notif: CopilotClientNotification) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ localProcessServer?.sendCopilotNotification(notif) { error in
+ if let error = error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume()
+ }
+ }
+ }
+ }
+}
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
index abad912e..8b4e30ea 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/ServerRequestHandler.swift
@@ -6,41 +6,50 @@ import LanguageServerProtocol
import Logger
protocol ServerRequestHandler {
- func handleRequest(_ request: AnyJSONRPCRequest, callback: @escaping (AnyJSONRPCResponse) -> Void)
+ func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?)
}
class ServerRequestHandlerImpl : ServerRequestHandler {
public static let shared = ServerRequestHandlerImpl()
private let conversationContextHandler: ConversationContextHandler = ConversationContextHandlerImpl.shared
+ private let watchedFilesHandler: WatchedFilesHandler = WatchedFilesHandlerImpl.shared
- func handleRequest(_ request: AnyJSONRPCRequest, callback: @escaping (AnyJSONRPCResponse) -> Void) {
+ func handleRequest(_ request: AnyJSONRPCRequest, workspaceURL: URL, callback: @escaping (AnyJSONRPCResponse) -> Void, service: GitHubCopilotService?) {
let methodName = request.method
- switch methodName {
- case "conversation/context":
- do {
+ do {
+ switch methodName {
+ case "conversation/context":
let params = try JSONEncoder().encode(request.params)
let contextParams = try JSONDecoder().decode(ConversationContextParams.self, from: params)
conversationContextHandler.handleConversationContext(
ConversationContextRequest(id: request.id, method: request.method, params: contextParams),
completion: callback)
-
- } catch {
- callback(
- AnyJSONRPCResponse(
- id: request.id,
- result: JSONValue.array([
- JSONValue.null,
- JSONValue.hash([
- "code": .number(-32602/* Invalid params */),
- "message": .string("Error: \(error.localizedDescription)")])
- ])
- )
- )
- Logger.gitHubCopilot.error(error)
+
+ case "copilot/watchedFiles":
+ let params = try JSONEncoder().encode(request.params)
+ let watchedFilesParams = try JSONDecoder().decode(WatchedFilesParams.self, from: params)
+ watchedFilesHandler.handleWatchedFiles(WatchedFilesRequest(id: request.id, method: request.method, params: watchedFilesParams), workspaceURL: workspaceURL, completion: callback, service: service)
+
+ default:
+ break
}
- break
- default:
- break
+ } catch {
+ handleError(request, error: error, callback: callback)
}
}
+
+ private func handleError(_ request: AnyJSONRPCRequest, error: Error, callback: @escaping (AnyJSONRPCResponse) -> Void) {
+ callback(
+ AnyJSONRPCResponse(
+ id: request.id,
+ result: JSONValue.array([
+ JSONValue.null,
+ JSONValue.hash([
+ "code": .number(-32602/* Invalid params */),
+ "message": .string("Error: \(error.localizedDescription)")])
+ ])
+ )
+ )
+ Logger.gitHubCopilot.error(error)
+ }
}
diff --git a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
index 372a0156..5bdff99f 100644
--- a/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
+++ b/Tool/Sources/GitHubCopilotService/Services/GitHubCopilotConversationService.swift
@@ -16,7 +16,7 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
return try await service.createConversation(request.content,
workDoneToken: request.workDoneToken,
- workspaceFolder: request.workspaceFolder,
+ workspaceFolder: workspace.projectURL.path,
doc: nil,
skills: request.skills,
ignoredSkills: request.ignoredSkills,
@@ -34,7 +34,8 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
doc: nil,
ignoredSkills: request.ignoredSkills,
references: request.references ?? [],
- model: request.model)
+ model: request.model,
+ workspaceFolder: workspace.projectURL.path)
}
public func cancelProgress(_ workDoneToken: String, workspace: WorkspaceInfo) async throws {
@@ -62,4 +63,18 @@ public final class GitHubCopilotConversationService: ConversationServiceType {
guard let service = await serviceLocator.getService(from: workspace) else { return nil }
return try await service.models()
}
+
+ public func notifyDidChangeWatchedFiles(_ event: DidChangeWatchedFilesEvent, workspace: WorkspaceInfo) async throws {
+ guard let service = await serviceLocator.getService(from: workspace) else {
+ return
+ }
+
+ return try await service.notifyDidChangeWatchedFiles(.init(workspaceUri: event.workspaceUri, changes: event.changes))
+ }
+
+ public func agents(workspace: WorkspaceInfo) async throws -> [ChatAgent]? {
+ guard let service = await serviceLocator.getService(from: workspace) else { return nil }
+ return try await service.agents()
+ }
}
+
diff --git a/Tool/Sources/HostAppActivator/HostAppActivator.swift b/Tool/Sources/HostAppActivator/HostAppActivator.swift
new file mode 100644
index 00000000..0f540ac9
--- /dev/null
+++ b/Tool/Sources/HostAppActivator/HostAppActivator.swift
@@ -0,0 +1,121 @@
+import Foundation
+import AppKit
+import Logger
+
+public let HostAppURL = locateHostBundleURL(url: Bundle.main.bundleURL)
+
+public extension Notification.Name {
+ static let openSettingsWindowRequest = Notification
+ .Name("com.github.CopilotForXcode.OpenSettingsWindowRequest")
+}
+
+public enum GitHubCopilotForXcodeSettingsLaunchError: Error, LocalizedError {
+ case appNotFound
+ case openFailed(errorDescription: String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .appNotFound:
+ return "\(hostAppName()) settings application not found"
+ case let .openFailed(errorDescription):
+ return "Failed to launch \(hostAppName()) settings (\(errorDescription))"
+ }
+ }
+}
+
+public func getRunningHostApp() -> NSRunningApplication? {
+ return NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleIdentifier == (Bundle.main.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String)
+ })
+}
+
+public func launchHostAppSettings() throws {
+ // Try the AppleScript approach first, but only if app is already running
+ if let hostApp = getRunningHostApp() {
+ let activated = hostApp.activate(options: [.activateIgnoringOtherApps])
+ Logger.ui.info("\(hostAppName()) activated: \(activated)")
+
+ let scriptSuccess = tryLaunchWithAppleScript()
+
+ // If AppleScript fails, fall back to notification center
+ if !scriptSuccess {
+ DistributedNotificationCenter.default().postNotificationName(
+ .openSettingsWindowRequest,
+ object: nil
+ )
+ Logger.ui.info("\(hostAppName()) settings notification sent after activation")
+ return
+ }
+ } else {
+ // If app is not running, launch it with the settings flag
+ try launchHostAppWithArgs(args: ["--settings"])
+ }
+}
+
+private func tryLaunchWithAppleScript() -> Bool {
+ // Try to launch settings using AppleScript
+ let script = """
+ tell application "\(hostAppName())"
+ activate
+ tell application "System Events"
+ keystroke "," using command down
+ end tell
+ end tell
+ """
+
+ var error: NSDictionary?
+ if let scriptObject = NSAppleScript(source: script) {
+ scriptObject.executeAndReturnError(&error)
+
+ // Log the result
+ if let error = error {
+ Logger.ui.info("\(hostAppName()) settings script error: \(error)")
+ return false
+ }
+
+ Logger.ui.info("\(hostAppName()) settings opened successfully via AppleScript")
+ return true
+ }
+
+ return false
+}
+
+public func launchHostAppDefault() throws {
+ try launchHostAppWithArgs(args: nil)
+}
+
+func launchHostAppWithArgs(args: [String]?) throws {
+ guard let appURL = HostAppURL else {
+ throw GitHubCopilotForXcodeSettingsLaunchError.appNotFound
+ }
+
+ Task {
+ let configuration = NSWorkspace.OpenConfiguration()
+ if let args {
+ configuration.arguments = args
+ }
+ configuration.activates = true
+
+ try await NSWorkspace.shared
+ .openApplication(at: appURL, configuration: configuration)
+ }
+}
+
+func locateHostBundleURL(url: URL) -> URL? {
+ var nextURL = url
+ while nextURL.path != "/" {
+ nextURL = nextURL.deletingLastPathComponent()
+ if nextURL.lastPathComponent.hasSuffix(".app") {
+ return nextURL
+ }
+ }
+ let devAppURL = url
+ .deletingLastPathComponent()
+ .appendingPathComponent("GitHub Copilot for Xcode Dev.app")
+ return devAppURL
+}
+
+func hostAppName() -> String {
+ return Bundle.main.object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
+ ?? "GitHub Copilot for Xcode"
+}
diff --git a/Tool/Sources/SharedUIComponents/InstructionView.swift b/Tool/Sources/SharedUIComponents/InstructionView.swift
index 73de164a..87eea3d0 100644
--- a/Tool/Sources/SharedUIComponents/InstructionView.swift
+++ b/Tool/Sources/SharedUIComponents/InstructionView.swift
@@ -27,6 +27,9 @@ public struct Instruction: View {
Label("to reference context", systemImage: "paperclip")
.foregroundColor(Color("DescriptionForegroundColor"))
.font(.system(size: 14))
+ Text("@ to chat with extensions")
+ .foregroundColor(Color("DescriptionForegroundColor"))
+ .font(.system(size: 14))
Text("Type / to use commands")
.foregroundColor(Color("DescriptionForegroundColor"))
.font(.system(size: 14))
diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift
new file mode 100644
index 00000000..8057b106
--- /dev/null
+++ b/Tool/Sources/Workspace/FileChangeWatcher/FSEventProvider.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+protocol FSEventProvider {
+ func createEventStream(
+ paths: CFArray,
+ latency: CFTimeInterval,
+ flags: UInt32,
+ callback: @escaping FSEventStreamCallback,
+ context: UnsafeMutablePointer
+ ) -> FSEventStreamRef?
+
+ func startStream(_ stream: FSEventStreamRef)
+ func stopStream(_ stream: FSEventStreamRef)
+ func invalidateStream(_ stream: FSEventStreamRef)
+ func releaseStream(_ stream: FSEventStreamRef)
+ func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue)
+}
+
+class FileChangeWatcherFSEventProvider: FSEventProvider {
+ init() {}
+
+ func createEventStream(
+ paths: CFArray,
+ latency: CFTimeInterval,
+ flags: UInt32,
+ callback: @escaping FSEventStreamCallback,
+ context: UnsafeMutablePointer
+ ) -> FSEventStreamRef? {
+ return FSEventStreamCreate(
+ kCFAllocatorDefault,
+ callback,
+ context,
+ paths,
+ FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
+ latency,
+ flags
+ )
+ }
+
+ func startStream(_ stream: FSEventStreamRef) {
+ FSEventStreamStart(stream)
+ }
+
+ func stopStream(_ stream: FSEventStreamRef) {
+ FSEventStreamStop(stream)
+ }
+
+ func invalidateStream(_ stream: FSEventStreamRef) {
+ FSEventStreamInvalidate(stream)
+ }
+
+ func releaseStream(_ stream: FSEventStreamRef) {
+ FSEventStreamRelease(stream)
+ }
+
+ func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue) {
+ FSEventStreamSetDispatchQueue(stream, queue)
+ }
+}
diff --git a/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift
new file mode 100644
index 00000000..f89a90de
--- /dev/null
+++ b/Tool/Sources/Workspace/FileChangeWatcher/FileChangeWatcher.swift
@@ -0,0 +1,401 @@
+import Foundation
+import System
+import Logger
+import CoreServices
+import LanguageServerProtocol
+import XcodeInspector
+
+public typealias PublisherType = (([FileEvent]) -> Void)
+
+protocol FileChangeWatcher {
+ func onFileCreated(file: URL)
+ func onFileChanged(file: URL)
+ func onFileDeleted(file: URL)
+
+ func addPaths(_ paths: [URL])
+ func removePaths(_ paths: [URL])
+}
+
+public final class BatchingFileChangeWatcher: FileChangeWatcher {
+ private var watchedPaths: [URL]
+ private let changePublisher: PublisherType
+ private let publishInterval: TimeInterval
+
+ private var pendingEvents: [FileEvent] = []
+ private var timer: Timer?
+ private let eventQueue: DispatchQueue
+ private let fsEventQueue: DispatchQueue
+ private var eventStream: FSEventStreamRef?
+ private(set) public var isWatching = false
+
+ // Dependencies injected for testing
+ private let fsEventProvider: FSEventProvider
+
+ public var paths: [URL] { watchedPaths }
+
+ /// TODO: set a proper value for stdio
+ public static let maxEventPublishSize = 100
+
+ init(
+ watchedPaths: [URL],
+ changePublisher: @escaping PublisherType,
+ publishInterval: TimeInterval = 3.0,
+ fsEventProvider: FSEventProvider = FileChangeWatcherFSEventProvider()
+ ) {
+ self.watchedPaths = watchedPaths
+ self.changePublisher = changePublisher
+ self.publishInterval = publishInterval
+ self.fsEventProvider = fsEventProvider
+ self.eventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcher")
+ self.fsEventQueue = DispatchQueue(label: "com.github.copilot.filechangewatcherfseventstream", qos: .utility)
+
+ self.start()
+ }
+
+ private func updateWatchedPaths(_ paths: [URL]) {
+ guard isWatching, paths != watchedPaths else { return }
+ stopWatching()
+ watchedPaths = paths
+ _ = startWatching()
+ }
+
+ public func addPaths(_ paths: [URL]) {
+ let newPaths = paths.filter { !watchedPaths.contains($0) }
+ if !newPaths.isEmpty {
+ let updatedPaths = watchedPaths + newPaths
+ updateWatchedPaths(updatedPaths)
+ }
+ }
+
+ public func removePaths(_ paths: [URL]) {
+ let updatedPaths = watchedPaths.filter { !paths.contains($0) }
+ if updatedPaths.count != watchedPaths.count {
+ updateWatchedPaths(updatedPaths)
+ }
+ }
+
+ internal func start() {
+ guard !isWatching else { return }
+
+ guard self.startWatching() else {
+ Logger.client.info("Failed to start watching for: \(watchedPaths)")
+ return
+ }
+ self.startPublishTimer()
+ isWatching = true
+ }
+
+ deinit {
+ stopWatching()
+ self.timer?.invalidate()
+ }
+
+ internal func startPublishTimer() {
+ guard self.timer == nil else { return }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.timer = Timer.scheduledTimer(withTimeInterval: self.publishInterval, repeats: true) { [weak self] _ in
+ self?.publishChanges()
+ }
+ }
+ }
+
+ internal func addEvent(file: URL, type: FileChangeType) {
+ eventQueue.async {
+ self.pendingEvents.append(FileEvent(uri: file.absoluteString, type: type))
+ }
+ }
+
+ public func onFileCreated(file: URL) {
+ addEvent(file: file, type: .created)
+ }
+
+ public func onFileChanged(file: URL) {
+ addEvent(file: file, type: .changed)
+ }
+
+ public func onFileDeleted(file: URL) {
+ addEvent(file: file, type: .deleted)
+ }
+
+ private func publishChanges() {
+ eventQueue.async {
+ guard !self.pendingEvents.isEmpty else { return }
+
+ var compressedEvent: [String: FileEvent] = [:]
+ for event in self.pendingEvents {
+ let existingEvent = compressedEvent[event.uri]
+
+ guard existingEvent != nil else {
+ compressedEvent[event.uri] = event
+ continue
+ }
+
+ if event.type == .deleted { /// file deleted. Cover created and changed event
+ compressedEvent[event.uri] = event
+ } else if event.type == .created { /// file created. Cover deleted and changed event
+ compressedEvent[event.uri] = event
+ } else if event.type == .changed {
+ if existingEvent?.type != .created { /// file changed. Won't cover created event
+ compressedEvent[event.uri] = event
+ }
+ }
+ }
+
+ let compressedEventArray: [FileEvent] = Array(compressedEvent.values)
+
+ let changes = Array(compressedEventArray.prefix(BatchingFileChangeWatcher.maxEventPublishSize))
+ if compressedEventArray.count > BatchingFileChangeWatcher.maxEventPublishSize {
+ self.pendingEvents = Array(compressedEventArray[BatchingFileChangeWatcher.maxEventPublishSize.. Bool {
+ var isEventStreamStarted = false
+
+ var context = FSEventStreamContext()
+ context.info = Unmanaged.passUnretained(self).toOpaque()
+
+ let paths = watchedPaths.map { $0.path } as CFArray
+ let flags = UInt32(
+ kFSEventStreamCreateFlagFileEvents |
+ kFSEventStreamCreateFlagNoDefer |
+ kFSEventStreamCreateFlagWatchRoot
+ )
+
+ eventStream = fsEventProvider.createEventStream(
+ paths: paths,
+ latency: 1, // 1 second latency,
+ flags: flags,
+ callback: { _, clientCallbackInfo, numEvents, eventPaths, eventFlags, _ in
+ guard let clientCallbackInfo = clientCallbackInfo else { return }
+ let watcher = Unmanaged.fromOpaque(clientCallbackInfo).takeUnretainedValue()
+ watcher.processEvent(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
+ },
+ context: &context
+ )
+
+ if let eventStream = eventStream {
+ fsEventProvider.setDispatchQueue(eventStream, queue: fsEventQueue)
+ fsEventProvider.startStream(eventStream)
+ isEventStreamStarted = true
+ }
+
+ return isEventStreamStarted
+ }
+
+ /// Stops watching for file changes
+ internal func stopWatching() {
+ guard isWatching, let eventStream = eventStream else { return }
+
+ fsEventProvider.stopStream(eventStream)
+ fsEventProvider.invalidateStream(eventStream)
+ fsEventProvider.releaseStream(eventStream)
+ self.eventStream = nil
+ isWatching = false
+
+ Logger.client.info("Stoped watching for file changes in \(watchedPaths)")
+ }
+
+ public func processEvent(numEvents: CFIndex, eventPaths: UnsafeRawPointer, eventFlags: UnsafePointer) {
+ let pathsPtr = eventPaths.bindMemory(to: UnsafeMutableRawPointer.self, capacity: numEvents)
+
+ for i in 0.. Bool {
+ if let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]),
+ resourceValues.isDirectory == true { return true }
+
+ if supportedFileExtensions.contains(url.pathExtension.lowercased()) == false { return true }
+
+ if WorkspaceFile.isXCProject(url) || WorkspaceFile.isXCWorkspace(url) { return true }
+
+ if WorkspaceFile.matchesPatterns(url, patterns: skipPatterns) { return true }
+
+ // TODO: check if url is ignored by git / ide
+
+ return false
+ }
+}
+
+public class FileChangeWatcherService {
+ internal var watcher: BatchingFileChangeWatcher?
+ /// for watching projects added or removed
+ private var timer: Timer?
+ private var projectWatchingInterval: TimeInterval = 3.0
+
+ private(set) public var workspaceURL: URL
+ private(set) public var publisher: PublisherType
+
+ // Dependencies injected for testing
+ internal let workspaceFileProvider: WorkspaceFileProvider
+ internal let watcherFactory: ([URL], @escaping PublisherType) -> BatchingFileChangeWatcher
+
+ public init(
+ _ workspaceURL: URL,
+ publisher: @escaping PublisherType,
+ publishInterval: TimeInterval = 3.0,
+ projectWatchingInterval: TimeInterval = 3.0,
+ workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(),
+ watcherFactory: (([URL], @escaping PublisherType) -> BatchingFileChangeWatcher)? = nil
+ ) {
+ self.workspaceURL = workspaceURL
+ self.publisher = publisher
+ self.workspaceFileProvider = workspaceFileProvider
+ self.watcherFactory = watcherFactory ?? { projectURLs, publisher in
+ BatchingFileChangeWatcher(watchedPaths: projectURLs, changePublisher: publisher, publishInterval: publishInterval)
+ }
+ }
+
+ deinit {
+ self.watcher = nil
+ self.timer?.invalidate()
+ }
+
+ internal func startWatchingProject() {
+ guard timer == nil else { return }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ self.timer = Timer.scheduledTimer(withTimeInterval: self.projectWatchingInterval, repeats: true) { [weak self] _ in
+ guard let self, let watcher = self.watcher else { return }
+
+ let watchingProjects = Set(watcher.paths)
+ let projects = Set(self.workspaceFileProvider.getSubprojectURLs(in: self.workspaceURL))
+
+ /// find added projects
+ let addedProjects = projects.subtracting(watchingProjects)
+ self.onProjectAdded(Array(addedProjects))
+
+ /// find removed projects
+ let removedProjects = watchingProjects.subtracting(projects)
+ self.onProjectRemoved(Array(removedProjects))
+ }
+ }
+ }
+
+ public func startWatching() {
+ guard workspaceURL.path != "/" else { return }
+
+ guard watcher == nil else { return }
+
+ let projects = workspaceFileProvider.getSubprojectURLs(in: workspaceURL)
+
+ watcher = watcherFactory(projects, publisher)
+ Logger.client.info("Started watching for file changes in \(projects)")
+
+ startWatchingProject()
+ }
+
+ internal func onProjectAdded(_ projectURLs: [URL]) {
+ guard let watcher = watcher, projectURLs.count > 0 else { return }
+
+ watcher.addPaths(projectURLs)
+
+ Logger.client.info("Started watching for file changes in \(projectURLs)")
+
+ /// sync all the files as created in the project when added
+ for projectURL in projectURLs {
+ let files = workspaceFileProvider.getFilesInActiveWorkspace(
+ workspaceURL: projectURL,
+ workspaceRootURL: projectURL
+ )
+ publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) })
+ }
+ }
+
+ internal func onProjectRemoved(_ projectURLs: [URL]) {
+ guard let watcher = watcher, projectURLs.count > 0 else { return }
+
+ watcher.removePaths(projectURLs)
+
+ Logger.client.info("Stopped watching for file changes in \(projectURLs)")
+
+ /// sync all the files as deleted in the project when removed
+ for projectURL in projectURLs {
+ let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL)
+ publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) })
+ }
+ }
+}
+
+@globalActor
+public enum PoolActor: GlobalActor {
+ public actor Actor {}
+ public static let shared = Actor()
+}
+
+public class FileChangeWatcherServicePool {
+
+ public static let shared = FileChangeWatcherServicePool()
+ private var servicePool: [URL: FileChangeWatcherService] = [:]
+
+ private init() {}
+
+ @PoolActor
+ public func watch(for workspaceURL: URL, publisher: @escaping PublisherType) {
+ guard workspaceURL.path != "/" else { return }
+
+ var validWorkspaceURL: URL? = nil
+ if WorkspaceFile.isXCWorkspace(workspaceURL) {
+ validWorkspaceURL = workspaceURL
+ } else if WorkspaceFile.isXCProject(workspaceURL) {
+ validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL)
+ }
+
+ guard let validWorkspaceURL else { return }
+
+ guard servicePool[workspaceURL] == nil else { return }
+
+ let watcherService = FileChangeWatcherService(validWorkspaceURL, publisher: publisher)
+ watcherService.startWatching()
+
+ servicePool[workspaceURL] = watcherService
+ }
+}
diff --git a/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift
new file mode 100644
index 00000000..65a3c56b
--- /dev/null
+++ b/Tool/Sources/Workspace/FileChangeWatcher/WorkspaceFileProvider.swift
@@ -0,0 +1,29 @@
+import Foundation
+import ConversationServiceProvider
+
+public protocol WorkspaceFileProvider {
+ func getSubprojectURLs(in workspaceURL: URL) -> [URL]
+ func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference]
+ func isXCProject(_ url: URL) -> Bool
+ func isXCWorkspace(_ url: URL) -> Bool
+}
+
+public class FileChangeWatcherWorkspaceFileProvider: WorkspaceFileProvider {
+ public init() {}
+
+ public func getSubprojectURLs(in workspaceURL: URL) -> [URL] {
+ return WorkspaceFile.getSubprojectURLs(in: workspaceURL)
+ }
+
+ public func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] {
+ return WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: workspaceURL, workspaceRootURL: workspaceRootURL)
+ }
+
+ public func isXCProject(_ url: URL) -> Bool {
+ return WorkspaceFile.isXCProject(url)
+ }
+
+ public func isXCWorkspace(_ url: URL) -> Bool {
+ return WorkspaceFile.isXCWorkspace(url)
+ }
+}
diff --git a/Tool/Sources/Workspace/WorkspaceFile.swift b/Tool/Sources/Workspace/WorkspaceFile.swift
new file mode 100644
index 00000000..bd1554ff
--- /dev/null
+++ b/Tool/Sources/Workspace/WorkspaceFile.swift
@@ -0,0 +1,180 @@
+import Foundation
+import Logger
+import ConversationServiceProvider
+
+public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml"]
+public let skipPatterns: [String] = [
+ ".git",
+ ".svn",
+ ".hg",
+ "CVS",
+ ".DS_Store",
+ "Thumbs.db",
+ "node_modules",
+ "bower_components"
+]
+
+
+public struct WorkspaceFile {
+
+ static func isXCWorkspace(_ url: URL) -> Bool {
+ return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path)
+ }
+
+ static func isXCProject(_ url: URL) -> Bool {
+ return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path)
+ }
+
+ static func getWorkspaceByProject(_ url: URL) -> URL? {
+ guard isXCProject(url) else { return nil }
+ let workspaceURL = url.appendingPathComponent("project.xcworkspace")
+
+ return isXCWorkspace(workspaceURL) ? workspaceURL : nil
+ }
+
+ static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] {
+ var subprojectURLs: [URL] = []
+ do {
+ let xml = try XMLDocument(data: data)
+ let fileRefs = try xml.nodes(forXPath: "//FileRef")
+ for fileRef in fileRefs {
+ if let fileRefElement = fileRef as? XMLElement,
+ let location = fileRefElement.attribute(forName: "location")?.stringValue {
+ var path = ""
+ if location.starts(with: "group:") {
+ path = location.replacingOccurrences(of: "group:", with: "")
+ } else if location.starts(with: "container:") {
+ path = location.replacingOccurrences(of: "container:", with: "")
+ } else if location.starts(with: "self:") {
+ // Handle "self:" referece - refers to the containing project directory
+ var workspaceURLCopy = workspaceURL
+ workspaceURLCopy.deleteLastPathComponent()
+ path = workspaceURLCopy.path
+
+ } else {
+ // Skip absolute paths such as absolute:/path/to/project
+ continue
+ }
+
+ if path.hasSuffix(".xcodeproj") {
+ path = (path as NSString).deletingLastPathComponent
+ }
+ let subprojectURL = path.isEmpty ? workspaceURL.deletingLastPathComponent() : workspaceURL.deletingLastPathComponent().appendingPathComponent(path)
+ if !subprojectURLs.contains(subprojectURL) {
+ subprojectURLs.append(subprojectURL)
+ }
+ }
+ }
+ } catch {
+ Logger.client.error("Failed to parse workspace file: \(error)")
+ }
+
+ return subprojectURLs
+ }
+
+ static func getSubprojectURLs(in workspaceURL: URL) -> [URL] {
+ let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata")
+ guard let data = try? Data(contentsOf: workspaceFile) else {
+ Logger.client.error("Failed to read workspace file at \(workspaceFile.path)")
+ return []
+ }
+
+ return getSubprojectURLs(workspaceURL: workspaceURL, data: data)
+ }
+
+ static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool {
+ let fileName = url.lastPathComponent
+ for pattern in patterns {
+ if fnmatch(pattern, fileName, 0) == 0 {
+ return true
+ }
+ }
+ return false
+ }
+
+ public static func getFilesInActiveWorkspace(
+ workspaceURL: URL,
+ workspaceRootURL: URL,
+ shouldExcludeFile: ((URL) -> Bool)? = nil
+ ) -> [FileReference] {
+ var files: [FileReference] = []
+ do {
+ let fileManager = FileManager.default
+ var subprojects: [URL] = []
+ if isXCWorkspace(workspaceURL) {
+ subprojects = getSubprojectURLs(in: workspaceURL)
+ } else {
+ subprojects.append(workspaceRootURL)
+ }
+ for subproject in subprojects {
+ guard FileManager.default.fileExists(atPath: subproject.path) else {
+ continue
+ }
+
+ let enumerator = fileManager.enumerator(
+ at: subproject,
+ includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
+ options: [.skipsHiddenFiles]
+ )
+
+ while let fileURL = enumerator?.nextObject() as? URL {
+ // Skip items matching the specified pattern
+ if matchesPatterns(fileURL, patterns: skipPatterns) || isXCWorkspace(fileURL) ||
+ isXCProject(fileURL) {
+ enumerator?.skipDescendants()
+ continue
+ }
+
+ let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey])
+ // Handle directories if needed
+ if resourceValues.isDirectory == true {
+ continue
+ }
+
+ guard resourceValues.isRegularFile == true else { continue }
+ if supportedFileExtensions.contains(fileURL.pathExtension.lowercased()) == false {
+ continue
+ }
+
+ // Apply the custom file exclusion check if provided
+ if let shouldExcludeFile = shouldExcludeFile,
+ shouldExcludeFile(fileURL) { continue }
+
+ let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "")
+ let fileName = fileURL.lastPathComponent
+
+ let file = FileReference(url: fileURL, relativePath: relativePath, fileName: fileName)
+ files.append(file)
+ }
+ }
+ } catch {
+ Logger.client.error("Failed to get files in workspace: \(error)")
+ }
+
+ return files
+ }
+
+ /*
+ used for `project-context` skill. Get filed for watching for syncing to CLS
+ */
+ public static func getWatchedFiles(
+ workspaceURL: URL,
+ projectURL: URL,
+ excludeGitIgnoredFiles: Bool,
+ excludeIDEIgnoredFiles: Bool
+ ) -> [String] {
+ // Directly return for invalid workspace
+ guard workspaceURL.path != "/" else { return [] }
+
+ // TODO: implement
+ let shouldExcludeFile: ((URL) -> Bool)? = nil
+
+ let files = getFilesInActiveWorkspace(
+ workspaceURL: workspaceURL,
+ workspaceRootURL: projectURL,
+ shouldExcludeFile: shouldExcludeFile
+ )
+
+ return files.map { $0.url.absoluteString }
+ }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index af3fad7e..e0c3f0f1 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -88,7 +88,7 @@ public extension Workspace {
)
let clsStatus = await Status.shared.getCLSStatus()
- if clsStatus.isErrorStatus {
+ if clsStatus.isErrorStatus && clsStatus.message.contains("Completions limit reached") {
filespace.setError(clsStatus.message)
} else {
filespace.setError("")
diff --git a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
index 610b6c53..4b7d09cb 100644
--- a/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
+++ b/Tool/Sources/XPCShared/XPCCommunicationBridge.swift
@@ -1,5 +1,6 @@
import Foundation
import Logger
+import AppKit
public enum XPCCommunicationBridgeError: Swift.Error, LocalizedError {
case failedToCreateXPCConnection
@@ -79,3 +80,18 @@ extension XPCCommunicationBridge {
}
}
+@available(macOS 13.0, *)
+public func showBackgroundPermissionAlert() {
+ let alert = NSAlert()
+ alert.messageText = "Background Permission Required"
+ alert.informativeText = "GitHub Copilot for Xcode needs permission to run in the background. Without this permission, features won't work correctly."
+ alert.alertStyle = .warning
+
+ alert.addButton(withTitle: "Open Settings")
+ alert.addButton(withTitle: "Later")
+
+ let response = alert.runModal()
+ if response == .alertFirstButtonReturn {
+ NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension")!)
+ }
+}
diff --git a/Tool/Sources/XPCShared/XPCExtensionService.swift b/Tool/Sources/XPCShared/XPCExtensionService.swift
index 4446b2c1..b5309612 100644
--- a/Tool/Sources/XPCShared/XPCExtensionService.swift
+++ b/Tool/Sources/XPCShared/XPCExtensionService.swift
@@ -148,11 +148,17 @@ public class XPCExtensionService {
}
}
- public func openChat(editorContent: EditorContent) async throws -> UpdatedContent? {
- try await suggestionRequest(
- editorContent,
- { $0.openChat }
- )
+ public func openChat() async throws {
+ try await withXPCServiceConnected {
+ service, continuation in
+ service.openChat { error in
+ if let error {
+ continuation.reject(error)
+ return
+ }
+ continuation.resume(())
+ }
+ } as Void
}
public func promptToCode(editorContent: EditorContent) async throws -> UpdatedContent? {
@@ -172,7 +178,6 @@ public class XPCExtensionService {
)
}
-
public func quitService() async throws {
try await withXPCServiceConnectedWithoutLaunching {
service, continuation in
diff --git a/Tool/Sources/XPCShared/XPCServiceProtocol.swift b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
index 427d21ef..00bf4307 100644
--- a/Tool/Sources/XPCShared/XPCServiceProtocol.swift
+++ b/Tool/Sources/XPCShared/XPCServiceProtocol.swift
@@ -33,8 +33,7 @@ public protocol XPCServiceProtocol {
withReply reply: @escaping (_ updatedContent: Data?, Error?) -> Void
)
func openChat(
- editorContent: Data,
- withReply reply: @escaping (Data?, Error?) -> Void
+ withReply reply: @escaping (Error?) -> Void
)
func promptToCode(
editorContent: Data,
@@ -155,4 +154,3 @@ extension ExtensionServiceRequestType {
}
}
}
-
diff --git a/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift
new file mode 100644
index 00000000..f69da9ad
--- /dev/null
+++ b/Tool/Tests/WorkspaceTests/FileChangeWatcherTests.swift
@@ -0,0 +1,345 @@
+import XCTest
+import Foundation
+import CoreServices
+import LanguageServerProtocol
+import ConversationServiceProvider
+@testable import Workspace
+
+// MARK: - Mocks for Testing
+
+class MockFSEventProvider: FSEventProvider {
+ var createdStream: FSEventStreamRef?
+ var didStartStream = false
+ var didStopStream = false
+ var didInvalidateStream = false
+ var didReleaseStream = false
+ var didSetDispatchQueue = false
+ var registeredCallback: FSEventStreamCallback?
+ var registeredContext: UnsafeMutablePointer?
+
+ var simulatedFiles: [String] = []
+
+ func createEventStream(
+ paths: CFArray,
+ latency: CFTimeInterval,
+ flags: UInt32,
+ callback: @escaping FSEventStreamCallback,
+ context: UnsafeMutablePointer
+ ) -> FSEventStreamRef? {
+ registeredCallback = callback
+ registeredContext = context
+ let stream = unsafeBitCast(1, to: FSEventStreamRef.self)
+ createdStream = stream
+ return stream
+ }
+
+ func startStream(_ stream: FSEventStreamRef) {
+ didStartStream = true
+ }
+
+ func stopStream(_ stream: FSEventStreamRef) {
+ didStopStream = true
+ }
+
+ func invalidateStream(_ stream: FSEventStreamRef) {
+ didInvalidateStream = true
+ }
+
+ func releaseStream(_ stream: FSEventStreamRef) {
+ didReleaseStream = true
+ }
+
+ func setDispatchQueue(_ stream: FSEventStreamRef, queue: DispatchQueue) {
+ didSetDispatchQueue = true
+ }
+}
+
+class MockWorkspaceFileProvider: WorkspaceFileProvider {
+
+ var subprojects: [URL] = []
+ var filesInWorkspace: [FileReference] = []
+ var xcProjectPaths: Set = []
+ var xcWorkspacePaths: Set = []
+
+ func getSubprojectURLs(in workspace: URL) -> [URL] {
+ return subprojects
+ }
+
+ func getFilesInActiveWorkspace(workspaceURL: URL, workspaceRootURL: URL) -> [FileReference] {
+ return filesInWorkspace
+ }
+
+ func isXCProject(_ url: URL) -> Bool {
+ return xcProjectPaths.contains(url.path)
+ }
+
+ func isXCWorkspace(_ url: URL) -> Bool {
+ return xcWorkspacePaths.contains(url.path)
+ }
+}
+
+// MARK: - Tests for BatchingFileChangeWatcher
+
+final class BatchingFileChangeWatcherTests: XCTestCase {
+ var mockFSEventProvider: MockFSEventProvider!
+ var publishedEvents: [[FileEvent]] = []
+
+ override func setUp() {
+ super.setUp()
+ mockFSEventProvider = MockFSEventProvider()
+ publishedEvents = []
+ }
+
+ func createWatcher(projectURL: URL = URL(fileURLWithPath: "/test/project")) -> BatchingFileChangeWatcher {
+ return BatchingFileChangeWatcher(
+ watchedPaths: [projectURL],
+ changePublisher: { [weak self] events in
+ self?.publishedEvents.append(events)
+ },
+ publishInterval: 0.1,
+ fsEventProvider: mockFSEventProvider
+ )
+ }
+
+ func testInitSetsUpTimerAndFileWatching() {
+ let _ = createWatcher()
+
+ XCTAssertNotNil(mockFSEventProvider.createdStream)
+ XCTAssertTrue(mockFSEventProvider.didStartStream)
+ }
+
+ func testDeinitCleansUpResources() {
+ var watcher: BatchingFileChangeWatcher? = createWatcher()
+ weak var weakWatcher = watcher
+
+ watcher = nil
+
+ // Wait for the watcher to be deallocated
+ let startTime = Date()
+ let timeout: TimeInterval = 1.0
+
+ while weakWatcher != nil && Date().timeIntervalSince(startTime) < timeout {
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
+ }
+
+ XCTAssertTrue(mockFSEventProvider.didStopStream)
+ XCTAssertTrue(mockFSEventProvider.didInvalidateStream)
+ XCTAssertTrue(mockFSEventProvider.didReleaseStream)
+ }
+
+ func testAddingEventsAndPublishing() {
+ let watcher = createWatcher()
+ let fileURL = URL(fileURLWithPath: "/test/project/file.swift")
+
+ watcher.onFileCreated(file: fileURL)
+
+ // No events should be published yet
+ XCTAssertTrue(publishedEvents.isEmpty)
+
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ // Only verify array contents if we have events
+ guard !publishedEvents.isEmpty else { return }
+
+ XCTAssertEqual(publishedEvents[0].count, 1)
+ XCTAssertEqual(publishedEvents[0][0].uri, fileURL.absoluteString)
+ XCTAssertEqual(publishedEvents[0][0].type, .created)
+ }
+
+ func testProcessingFSEvents() {
+ let watcher = createWatcher()
+ let fileURL = URL(fileURLWithPath: "/test/project/file.swift")
+
+ // Test file creation - directly call methods instead of simulating FS events
+ watcher.onFileCreated(file: fileURL)
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ guard !publishedEvents.isEmpty else { return }
+ XCTAssertEqual(publishedEvents[0].count, 1)
+ XCTAssertEqual(publishedEvents[0][0].type, .created)
+
+ // Test file modification
+ publishedEvents = []
+ watcher.onFileChanged(file: fileURL)
+
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ guard !publishedEvents.isEmpty else { return }
+ XCTAssertEqual(publishedEvents[0].count, 1)
+ XCTAssertEqual(publishedEvents[0][0].type, .changed)
+
+ // Test file deletion
+ publishedEvents = []
+ watcher.onFileDeleted(file: fileURL)
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ guard !publishedEvents.isEmpty else { return }
+ XCTAssertEqual(publishedEvents[0].count, 1)
+ XCTAssertEqual(publishedEvents[0][0].type, .deleted)
+ }
+}
+
+extension BatchingFileChangeWatcherTests {
+ func waitForPublishedEvents(timeout: TimeInterval = 1.0) -> Bool {
+ let start = Date()
+ while publishedEvents.isEmpty && Date().timeIntervalSince(start) < timeout {
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
+ }
+ return !publishedEvents.isEmpty
+ }
+}
+
+// MARK: - Tests for FileChangeWatcherService
+
+final class FileChangeWatcherServiceTests: XCTestCase {
+ var mockWorkspaceFileProvider: MockWorkspaceFileProvider!
+ var publishedEvents: [[FileEvent]] = []
+ var createdWatchers: [[URL]: BatchingFileChangeWatcher] = [:]
+
+ override func setUp() {
+ super.setUp()
+ mockWorkspaceFileProvider = MockWorkspaceFileProvider()
+ publishedEvents = []
+ createdWatchers = [:]
+ }
+
+ func createService(workspaceURL: URL = URL(fileURLWithPath: "/test/workspace")) -> FileChangeWatcherService {
+ return FileChangeWatcherService(
+ workspaceURL,
+ publisher: { [weak self] events in
+ self?.publishedEvents.append(events)
+ },
+ publishInterval: 0.1,
+ projectWatchingInterval: 0.1,
+ workspaceFileProvider: mockWorkspaceFileProvider,
+ watcherFactory: { projectURLs, publisher in
+ let watcher = BatchingFileChangeWatcher(
+ watchedPaths: projectURLs,
+ changePublisher: publisher,
+ fsEventProvider: MockFSEventProvider()
+ )
+ self.createdWatchers[projectURLs] = watcher
+ return watcher
+ }
+ )
+ }
+
+ func testStartWatchingCreatesWatchersForProjects() {
+ let project1 = URL(fileURLWithPath: "/test/workspace/project1")
+ let project2 = URL(fileURLWithPath: "/test/workspace/project2")
+ mockWorkspaceFileProvider.subprojects = [project1, project2]
+
+ let service = createService()
+ service.startWatching()
+
+ XCTAssertEqual(createdWatchers.count, 1)
+ XCTAssertNotNil(createdWatchers[[project1, project2]])
+ }
+
+ func testStartWatchingDoesNotCreateWatcherForRootDirectory() {
+ let service = createService(workspaceURL: URL(fileURLWithPath: "/"))
+ service.startWatching()
+
+ XCTAssertTrue(createdWatchers.isEmpty)
+ }
+
+ func testProjectMonitoringDetectsAddedProjects() {
+ let workspace = URL(fileURLWithPath: "/test/workspace")
+ let project1 = URL(fileURLWithPath: "/test/workspace/project1")
+ mockWorkspaceFileProvider.subprojects = [project1]
+
+ let service = createService(workspaceURL: workspace)
+ service.startWatching()
+
+ XCTAssertEqual(createdWatchers.count, 1)
+
+ // Simulate adding a new project
+ let project2 = URL(fileURLWithPath: "/test/workspace/project2")
+ mockWorkspaceFileProvider.subprojects = [project1, project2]
+
+ // Set up mock files for the added project
+ let file1URL = URL(fileURLWithPath: "/test/workspace/project2/file1.swift")
+ let file1 = FileReference(
+ url: file1URL,
+ relativePath: file1URL.relativePath,
+ fileName: file1URL.lastPathComponent
+ )
+ let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift")
+ let file2 = FileReference(
+ url: file2URL,
+ relativePath: file2URL.relativePath,
+ fileName: file2URL.lastPathComponent
+ )
+ mockWorkspaceFileProvider.filesInWorkspace = [file1, file2]
+
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ XCTAssertEqual(createdWatchers.count, 1)
+
+ guard !publishedEvents.isEmpty else { return }
+
+ // Verify file events were published
+ XCTAssertEqual(publishedEvents[0].count, 2)
+
+ // Verify both files were reported as created
+ XCTAssertEqual(publishedEvents[0][0].type, .created)
+ XCTAssertEqual(publishedEvents[0][1].type, .created)
+ }
+
+ func testProjectMonitoringDetectsRemovedProjects() {
+ let workspace = URL(fileURLWithPath: "/test/workspace")
+ let project1 = URL(fileURLWithPath: "/test/workspace/project1")
+ let project2 = URL(fileURLWithPath: "/test/workspace/project2")
+ mockWorkspaceFileProvider.subprojects = [project1, project2]
+
+ let service = createService(workspaceURL: workspace)
+ service.startWatching()
+
+ XCTAssertEqual(createdWatchers.count, 1)
+
+ // Simulate removing a project
+ mockWorkspaceFileProvider.subprojects = [project1]
+
+ // Set up mock files for the removed project
+ let file1URL = URL(fileURLWithPath: "/test/workspace/project2/file1.swift")
+ let file1 = FileReference(
+ url: file1URL,
+ relativePath: file1URL.relativePath,
+ fileName: file1URL.lastPathComponent
+ )
+ let file2URL = URL(fileURLWithPath: "/test/workspace/project2/file2.swift")
+ let file2 = FileReference(
+ url: file2URL,
+ relativePath: file2URL.relativePath,
+ fileName: file2URL.lastPathComponent
+ )
+ mockWorkspaceFileProvider.filesInWorkspace = [file1, file2]
+
+ // Clear published events from setup
+ publishedEvents = []
+
+ XCTAssertTrue(waitForPublishedEvents(), "No events were published within timeout")
+
+ guard !publishedEvents.isEmpty else { return }
+
+ // Verify the watcher was removed
+ XCTAssertEqual(createdWatchers.count, 1)
+
+ // Verify file events were published
+ XCTAssertEqual(publishedEvents[0].count, 2)
+
+ // Verify both files were reported as deleted
+ XCTAssertEqual(publishedEvents[0][0].type, .deleted)
+ XCTAssertEqual(publishedEvents[0][1].type, .deleted)
+ }
+}
+
+extension FileChangeWatcherServiceTests {
+ func waitForPublishedEvents(timeout: TimeInterval = 3.0) -> Bool {
+ let start = Date()
+ while publishedEvents.isEmpty && Date().timeIntervalSince(start) < timeout {
+ RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
+ }
+ return !publishedEvents.isEmpty
+ }
+}
diff --git a/Core/Tests/ConversationTabTests/ContextUtilsTests.swift b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift
similarity index 90%
rename from Core/Tests/ConversationTabTests/ContextUtilsTests.swift
rename to Tool/Tests/WorkspaceTests/WorkspaceTests.swift
index e6048a94..091f26af 100644
--- a/Core/Tests/ConversationTabTests/ContextUtilsTests.swift
+++ b/Tool/Tests/WorkspaceTests/WorkspaceTests.swift
@@ -1,24 +1,24 @@
import XCTest
import Foundation
-@testable import ConversationTab
+@testable import Workspace
-class ContextUtilsTests: XCTestCase {
+class WorkspaceFileTests: XCTestCase {
func testMatchesPatterns() {
let url1 = URL(fileURLWithPath: "/path/to/file.swift")
let url2 = URL(fileURLWithPath: "/path/to/.git")
let patterns = [".git", ".svn"]
- XCTAssertTrue(ContextUtils.matchesPatterns(url2, patterns: patterns))
- XCTAssertFalse(ContextUtils.matchesPatterns(url1, patterns: patterns))
+ XCTAssertTrue(WorkspaceFile.matchesPatterns(url2, patterns: patterns))
+ XCTAssertFalse(WorkspaceFile.matchesPatterns(url1, patterns: patterns))
}
func testIsXCWorkspace() throws {
let tmpDir = try createTemporaryDirectory()
do {
let xcworkspaceURL = try createSubdirectory(in: tmpDir, withName: "myWorkspace.xcworkspace")
- XCTAssertFalse(ContextUtils.isXCWorkspace(xcworkspaceURL))
+ XCTAssertFalse(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
let xcworkspaceDataURL = try createFile(in: xcworkspaceURL, withName: "contents.xcworkspacedata", contents: "")
- XCTAssertTrue(ContextUtils.isXCWorkspace(xcworkspaceURL))
+ XCTAssertTrue(WorkspaceFile.isXCWorkspace(xcworkspaceURL))
} catch {
deleteDirectoryIfExists(at: tmpDir)
throw error
@@ -30,9 +30,9 @@ class ContextUtilsTests: XCTestCase {
let tmpDir = try createTemporaryDirectory()
do {
let xcprojectURL = try createSubdirectory(in: tmpDir, withName: "myProject.xcodeproj")
- XCTAssertFalse(ContextUtils.isXCProject(xcprojectURL))
+ XCTAssertFalse(WorkspaceFile.isXCProject(xcprojectURL))
let xcprojectDataURL = try createFile(in: xcprojectURL, withName: "project.pbxproj", contents: "")
- XCTAssertTrue(ContextUtils.isXCProject(xcprojectURL))
+ XCTAssertTrue(WorkspaceFile.isXCProject(xcprojectURL))
} catch {
deleteDirectoryIfExists(at: tmpDir)
throw error
@@ -48,7 +48,7 @@ class ContextUtilsTests: XCTestCase {
_ = try createFile(in: tmpDir, withName: "file1.swift", contents: "")
_ = try createFile(in: tmpDir, withName: "file2.swift", contents: "")
_ = try createSubdirectory(in: tmpDir, withName: ".git")
- let files = ContextUtils.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir)
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcprojectURL, workspaceRootURL: tmpDir)
let fileNames = files.map { $0.url.lastPathComponent }
XCTAssertEqual(files.count, 2)
XCTAssertTrue(fileNames.contains("file1.swift"))
@@ -90,7 +90,7 @@ class ContextUtilsTests: XCTestCase {
// Files under unrelated directories should be excluded
_ = try createFile(in: tmpDir, withName: "unrelatedFile1.swift", contents: "")
- let files = ContextUtils.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot)
+ let files = WorkspaceFile.getFilesInActiveWorkspace(workspaceURL: xcWorkspaceURL, workspaceRootURL: myWorkspaceRoot)
let fileNames = files.map { $0.url.lastPathComponent }
XCTAssertEqual(files.count, 2)
XCTAssertTrue(fileNames.contains("file1.swift"))
@@ -109,7 +109,7 @@ class ContextUtilsTests: XCTestCase {
_ = try createFileFor_contents_dot_xcworkspacedata(directory: xcworkspaceURL, fileRefs: [
"container:myProject.xcodeproj",
"group:myDependency"])
- let subprojectURLs = ContextUtils.getSubprojectURLs(in: xcworkspaceURL)
+ let subprojectURLs = WorkspaceFile.getSubprojectURLs(in: xcworkspaceURL)
XCTAssertEqual(subprojectURLs.count, 2)
XCTAssertEqual(subprojectURLs[0].path, tmpDir.path)
XCTAssertEqual(subprojectURLs[1].path, tmpDir.appendingPathComponent("myDependency").path)
@@ -147,7 +147,7 @@ class ContextUtilsTests: XCTestCase {
""".data(using: .utf8)!
- let subprojectURLs = ContextUtils.getSubprojectURLs(workspaceURL: workspaceURL, data: xcworkspaceData)
+ let subprojectURLs = WorkspaceFile.getSubprojectURLs(workspaceURL: workspaceURL, data: xcworkspaceData)
XCTAssertEqual(subprojectURLs.count, 5)
XCTAssertEqual(subprojectURLs[0].path, "/path/to/tryapp")
XCTAssertEqual(subprojectURLs[1].path, "/path/to")
diff --git a/for Sun Source Energy.png b/for Sun Source Energy.png
new file mode 100644
index 00000000..d9d5e179
Binary files /dev/null and b/for Sun Source Energy.png differ