import Foundation import System import Logger import CoreServices import LanguageServerProtocol import XcodeInspector public class FileChangeWatcherService { internal var watcher: DirectoryWatcherProtocol? private(set) public var workspaceURL: URL private(set) public var publisher: PublisherType private(set) public var publishInterval: TimeInterval private(set) public var directoryChangePublisher: PublisherType? // Dependencies injected for testing internal let workspaceFileProvider: WorkspaceFileProvider internal let watcherFactory: FileWatcherFactory // Watching workspace metadata file private var workspaceConfigFileWatcher: FileWatcherProtocol? private var isMonitoringWorkspaceConfigFile = false private let monitoringQueue = DispatchQueue(label: "com.github.copilot.workspaceMonitor", qos: .utility) private let configFileEventQueue = DispatchQueue(label: "com.github.copilot.workspaceEventMonitor", qos: .utility) public init( _ workspaceURL: URL, publisher: @escaping PublisherType, publishInterval: TimeInterval = 3.0, workspaceFileProvider: WorkspaceFileProvider = FileChangeWatcherWorkspaceFileProvider(), watcherFactory: FileWatcherFactory? = nil, directoryChangePublisher: PublisherType? ) { self.workspaceURL = workspaceURL self.publisher = publisher self.publishInterval = publishInterval self.workspaceFileProvider = workspaceFileProvider self.watcherFactory = watcherFactory ?? DefaultFileWatcherFactory() self.directoryChangePublisher = directoryChangePublisher } deinit { stopWorkspaceConfigFileMonitoring() self.watcher = nil } public func startWatching() { guard workspaceURL.path != "/" else { return } guard watcher == nil else { return } let projects = workspaceFileProvider.getProjects(by: workspaceURL) guard projects.count > 0 else { return } watcher = watcherFactory.createDirectoryWatcher( watchedPaths: projects, changePublisher: publisher, publishInterval: publishInterval, directoryChangePublisher: directoryChangePublisher ) Logger.client.info("Started watching for file changes in \(projects)") startWatchingProject() } internal func startWatchingProject() { if self.workspaceFileProvider.isXCWorkspace(self.workspaceURL) { guard !isMonitoringWorkspaceConfigFile else { return } isMonitoringWorkspaceConfigFile = true recreateConfigFileMonitor() } } private func recreateConfigFileMonitor() { let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") // Clean up existing monitor first cleanupCurrentMonitor() guard self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) else { Logger.client.info("[FileWatcher] contents.xcworkspacedata file not found at \(workspaceDataFile.path).") return } // Create SingleFileWatcher for the workspace file workspaceConfigFileWatcher = self.watcherFactory.createFileWatcher( fileURL: workspaceDataFile, dispatchQueue: configFileEventQueue, onFileModified: { [weak self] in self?.handleWorkspaceConfigFileChange() self?.scheduleMonitorRecreation(delay: 1.0) }, onFileDeleted: { [weak self] in self?.handleWorkspaceConfigFileChange() self?.scheduleMonitorRecreation(delay: 1.0) }, onFileRenamed: nil ) let _ = workspaceConfigFileWatcher?.startWatching() } private func handleWorkspaceConfigFileChange() { guard let watcher = self.watcher else { return } let workspaceDataFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") // Check if file still exists let fileExists = self.workspaceFileProvider.fileExists(atPath: workspaceDataFile.path) if fileExists { // File was modified, check for project changes let watchingProjects = Set(watcher.paths()) let projects = Set(self.workspaceFileProvider.getProjects(by: self.workspaceURL)) /// find added projects let addedProjects = projects.subtracting(watchingProjects) if !addedProjects.isEmpty { self.onProjectAdded(Array(addedProjects)) } /// find removed projects let removedProjects = watchingProjects.subtracting(projects) if !removedProjects.isEmpty { self.onProjectRemoved(Array(removedProjects)) } } else { Logger.client.info("[FileWatcher] contents.xcworkspacedata file was deleted") } } private func scheduleMonitorRecreation(delay: TimeInterval) { monitoringQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self, self.isMonitoringWorkspaceConfigFile else { return } self.recreateConfigFileMonitor() } } private func cleanupCurrentMonitor() { workspaceConfigFileWatcher?.stopWatching() workspaceConfigFileWatcher = nil } private func stopWorkspaceConfigFileMonitoring() { isMonitoringWorkspaceConfigFile = false cleanupCurrentMonitor() } internal func onProjectAdded(_ projectURLs: [URL]) { guard let watcher = watcher, projectURLs.count > 0 else { return } watcher.addPaths(projectURLs) Logger.client.info("Started watching for file changes in \(projectURLs)") /// sync all the files as created in the project when added for projectURL in projectURLs { let files = workspaceFileProvider.getFilesInActiveWorkspace( workspaceURL: projectURL, workspaceRootURL: projectURL ) publisher(files.map { .init(uri: $0.url.absoluteString, type: .created) }) } } internal func onProjectRemoved(_ projectURLs: [URL]) { guard let watcher = watcher, projectURLs.count > 0 else { return } watcher.removePaths(projectURLs) Logger.client.info("Stopped watching for file changes in \(projectURLs)") /// sync all the files as deleted in the project when removed for projectURL in projectURLs { let files = workspaceFileProvider.getFilesInActiveWorkspace(workspaceURL: projectURL, workspaceRootURL: projectURL) publisher(files.map { .init(uri: $0.url.absoluteString, type: .deleted) }) } } } @globalActor public enum PoolActor: GlobalActor { public actor Actor {} public static let shared = Actor() } public class FileChangeWatcherServicePool { public static let shared = FileChangeWatcherServicePool() private var servicePool: [URL: FileChangeWatcherService] = [:] private init() {} @PoolActor public func watch( for workspaceURL: URL, publisher: @escaping PublisherType, directoryChangePublisher: PublisherType? = nil ) { guard workspaceURL.path != "/" else { return } var validWorkspaceURL: URL? = nil if WorkspaceFile.isXCWorkspace(workspaceURL) { validWorkspaceURL = workspaceURL } else if WorkspaceFile.isXCProject(workspaceURL) { validWorkspaceURL = WorkspaceFile.getWorkspaceByProject(workspaceURL) } guard let validWorkspaceURL else { return } guard servicePool[workspaceURL] == nil else { return } let watcherService = FileChangeWatcherService( validWorkspaceURL, publisher: publisher, directoryChangePublisher: directoryChangePublisher ) watcherService.startWatching() servicePool[workspaceURL] = watcherService } }