import Dependencies import Foundation import Logger import XcodeInspector public struct WorkspacePoolDependencyKey: DependencyKey { public static var liveValue: WorkspacePool = .init() } public extension DependencyValues { var workspacePool: WorkspacePool { get { self[WorkspacePoolDependencyKey.self] } set { self[WorkspacePoolDependencyKey.self] = newValue } } } @globalActor public enum WorkspaceActor { public actor TheActor {} public static let shared = TheActor() } public class WorkspacePool { public enum Error: Swift.Error, LocalizedError { case invalidWorkspaceURL(URL) public var errorDescription: String? { switch self { case let .invalidWorkspaceURL(url): return "Invalid workspace URL: \(url)" } } } public internal(set) var workspaces: [URL: Workspace] = [:] var plugins = [ObjectIdentifier: (Workspace) -> WorkspacePlugin]() public init( workspaces: [URL: Workspace] = [:], plugins: [ObjectIdentifier: (Workspace) -> WorkspacePlugin] = [:] ) { self.workspaces = workspaces self.plugins = plugins } public func registerPlugin(_ plugin: @escaping (Workspace) -> Plugin) { let id = ObjectIdentifier(Plugin.self) let erasedPlugin: (Workspace) -> WorkspacePlugin = { plugin($0) } plugins[id] = erasedPlugin for workspace in workspaces.values { addPlugin(erasedPlugin, id: id, to: workspace) } } public func unregisterPlugin(_: Plugin.Type) { let id = ObjectIdentifier(Plugin.self) plugins[id] = nil for workspace in workspaces.values { removePlugin(id: id, from: workspace) } } public func fetchFilespaceIfExisted(fileURL: URL) -> Filespace? { let filespaces = workspaces.values.compactMap { $0.filespaces[fileURL] } if filespaces.isEmpty { return nil } if filespaces.count == 1 { return filespaces.first } Logger.workspacePool.info("Multiple workspaces found with file: \(fileURL)") // If multiple workspaces are found, return the first with a suggestion return filespaces.first { $0.presentingSuggestion != nil } ?? filespaces.first { $0.presentingNESSuggestion != nil } } public func fetchWorkspaceAndFilespace(fileURL: URL) -> (Workspace, Filespace)? { var workspace: Workspace? var filespace: Filespace? for wp in workspaces.values { if let fp = wp.filespaces[fileURL] { if fp.presentingSuggestion != nil || fp.presentingNESSuggestion != nil { return (wp, fp) } workspace = wp filespace = fp } } return workspace.flatMap { ws in filespace.map { fs in (ws, fs) } } } @WorkspaceActor public func fetchOrCreateWorkspace(workspaceURL: URL) async throws -> Workspace { guard workspaceURL != URL(fileURLWithPath: "/") else { throw Error.invalidWorkspaceURL(workspaceURL) } if let existed = workspaces[workspaceURL] { return existed } let new = createNewWorkspace(workspaceURL: workspaceURL) workspaces[workspaceURL] = new return new } @WorkspaceActor public func fetchOrCreateWorkspaceAndFilespace(fileURL: URL) async throws -> (workspace: Workspace, filespace: Filespace) { // If we can get the workspace URL directly. if let currentWorkspaceURL = await XcodeInspector.shared.safe.realtimeActiveWorkspaceURL { if let existed = workspaces[currentWorkspaceURL] { // Reuse the existed workspace. let filespace = try await existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } let new = createNewWorkspace(workspaceURL: currentWorkspaceURL) workspaces[currentWorkspaceURL] = new let filespace = try await new.createFilespaceIfNeeded(fileURL: fileURL) return (new, filespace) } // If not, we try to reuse a filespace if found. // // Sometimes, we can't get the project root path from Xcode window, for example, when the // quick open window in displayed. for workspace in workspaces.values { if let filespace = workspace.filespaces[fileURL] { return (workspace, filespace) } } // If we can't find the workspace URL, we will try to guess it. // Most of the time we won't enter this branch, just incase. if let workspaceURL = WorkspaceXcodeWindowInspector.extractProjectURL( workspaceURL: nil, documentURL: fileURL ) { let workspace = { if let existed = workspaces[workspaceURL] { return existed } // Reuse existed workspace if possible for (_, workspace) in workspaces { if fileURL.path.hasPrefix(workspace.projectRootURL.path) { return workspace } } return createNewWorkspace(workspaceURL: workspaceURL) }() let filespace = try await workspace.createFilespaceIfNeeded(fileURL: fileURL) workspaces[workspaceURL] = workspace workspace.refreshUpdateTime() return (workspace, filespace) } throw Workspace.CantFindWorkspaceError() } @WorkspaceActor public func removeWorkspace(url: URL) { workspaces[url] = nil } } extension WorkspacePool { func addPlugin( _ plugin: (Workspace) -> WorkspacePlugin, id: ObjectIdentifier, to workspace: Workspace ) { if workspace.plugins[id] != nil { return } workspace.plugins[id] = plugin(workspace) } func removePlugin(id: ObjectIdentifier, from workspace: Workspace) { workspace.plugins[id] = nil } func createNewWorkspace(workspaceURL: URL) -> Workspace { let new = Workspace(workspaceURL: workspaceURL) for (id, plugin) in plugins { addPlugin(plugin, id: id, to: new) } return new } }