diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift index 77ecc77fa8..655a66eab5 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift @@ -132,6 +132,7 @@ final class TaskNotificationHandler: ObservableObject { /// - toWorkspace: The workspace to restrict the task to. Defaults to `nil`, which is received by all workspaces. /// - action: The action being taken on the task. /// - model: The task contents. + @MainActor static func postTask(toWorkspace: URL? = nil, action: Action, model: TaskNotificationModel) { NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: [ "id": model.id, diff --git a/CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift b/CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift new file mode 100644 index 0000000000..9a64190da8 --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/ErrorDescriptionLabel.swift @@ -0,0 +1,36 @@ +// +// ErrorDescriptionLabel.swift +// CodeEdit +// +// Created by Khan Winter on 8/14/25. +// + +import SwiftUI + +struct ErrorDescriptionLabel: View { + let error: Error + + var body: some View { + VStack(alignment: .leading) { + if let error = error as? LocalizedError { + if let description = error.errorDescription { + Text(description) + } + + if let reason = error.failureReason { + Text(reason) + } + + if let recoverySuggestion = error.recoverySuggestion { + Text(recoverySuggestion) + } + } else { + Text(error.localizedDescription) + } + } + } +} + +#Preview { + ErrorDescriptionLabel(error: CancellationError()) +} diff --git a/CodeEdit/Features/LSP/Registry/Errors/PackageManagerError.swift b/CodeEdit/Features/LSP/Registry/Errors/PackageManagerError.swift new file mode 100644 index 0000000000..c440ac905e --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/Errors/PackageManagerError.swift @@ -0,0 +1,46 @@ +// +// PackageManagerError.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +import Foundation + +enum PackageManagerError: Error, LocalizedError { + case unknown + case packageManagerNotInstalled + case initializationFailed(String) + case installationFailed(String) + case invalidConfiguration + + var errorDescription: String? { + switch self { + case .unknown: + "Unknown error occurred" + case .packageManagerNotInstalled: + "The required package manager is not installed." + case .initializationFailed: + "Installation directory initialization failed." + case .installationFailed: + "Package installation failed." + case .invalidConfiguration: + "The package registry contained an invalid installation configuration." + } + } + + var failureReason: String? { + switch self { + case .unknown: + nil + case .packageManagerNotInstalled: + nil + case .initializationFailed(let string): + string + case .installationFailed(let string): + string + case .invalidConfiguration: + nil + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift b/CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift new file mode 100644 index 0000000000..68c1006e4e --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/Errors/RegistryManagerError.swift @@ -0,0 +1,47 @@ +// +// RegistryManagerError.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +import Foundation + +enum RegistryManagerError: Error, LocalizedError { + case installationRunning + case invalidResponse(statusCode: Int) + case downloadFailed(url: URL, error: Error) + case maxRetriesExceeded(url: URL, lastError: Error) + case writeFailed(error: Error) + case failedToSaveRegistryCache + + var errorDescription: String? { + switch self { + case .installationRunning: + "A package is already being installed." + case .invalidResponse(let statusCode): + "Invalid response received: \(statusCode)" + case .downloadFailed(let url, _): + "Download for \(url) error." + case .maxRetriesExceeded(let url, _): + "Maximum retries exceeded for url: \(url)" + case .writeFailed: + "Failed to write to file." + case .failedToSaveRegistryCache: + "Failed to write to registry cache." + } + } + + var failureReason: String? { + switch self { + case .installationRunning, .invalidResponse, .failedToSaveRegistryCache: + return nil + case .downloadFailed(_, let error), .maxRetriesExceeded(_, let error), .writeFailed(let error): + return if let error = error as? LocalizedError { + error.errorDescription + } else { + error.localizedDescription + } + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift deleted file mode 100644 index b1f6909607..0000000000 --- a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift +++ /dev/null @@ -1,185 +0,0 @@ -// -// InstallationQueueManager.swift -// CodeEdit -// -// Created by Abe Malla on 3/13/25. -// - -import Foundation - -/// A class to manage queued installations of language servers -final class InstallationQueueManager { - static let shared: InstallationQueueManager = .init() - - /// The maximum number of concurrent installations allowed - private let maxConcurrentInstallations: Int = 2 - /// Queue of pending installations - private var installationQueue: [(RegistryItem, (Result) -> Void)] = [] - /// Currently running installations - private var runningInstallations: Set = [] - /// Installation status dictionary - private var installationStatus: [String: PackageInstallationStatus] = [:] - - /// Add a package to the installation queue - func queueInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { - // If we're already at max capacity and this isn't already running, mark as queued - if runningInstallations.count >= maxConcurrentInstallations && !runningInstallations.contains(package.name) { - installationStatus[package.name] = .queued - installationQueue.append((package, completion)) - - // Notify UI that package is queued - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .installationStatusChanged, - object: nil, - userInfo: ["packageName": package.name, "status": PackageInstallationStatus.queued] - ) - } - } else { - startInstallation(package: package, completion: completion) - } - } - - /// Starts the actual installation process for a package - private func startInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { - installationStatus[package.name] = .installing - runningInstallations.insert(package.name) - - // Notify UI that installation is now in progress - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .installationStatusChanged, - object: nil, - userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installing] - ) - } - - Task { - do { - try await RegistryManager.shared.installPackage(package: package) - - // Notify UI that installation is complete - installationStatus[package.name] = .installed - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .installationStatusChanged, - object: nil, - userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installed] - ) - completion(.success(())) - } - } catch { - // Notify UI that installation failed - installationStatus[package.name] = .failed(error) - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .installationStatusChanged, - object: nil, - userInfo: ["packageName": package.name, "status": PackageInstallationStatus.failed(error)] - ) - completion(.failure(error)) - } - } - - runningInstallations.remove(package.name) - processNextInstallations() - } - } - - /// Process next installations from the queue if possible - private func processNextInstallations() { - while runningInstallations.count < maxConcurrentInstallations && !installationQueue.isEmpty { - let (package, completion) = installationQueue.removeFirst() - if runningInstallations.contains(package.name) { - continue - } - - startInstallation(package: package, completion: completion) - } - } - - /// Cancel an installation if it's in the queue - func cancelInstallation(packageName: String) { - installationQueue.removeAll { $0.0.name == packageName } - installationStatus[packageName] = .cancelled - runningInstallations.remove(packageName) - - // Notify UI that installation was cancelled - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .installationStatusChanged, - object: nil, - userInfo: ["packageName": packageName, "status": PackageInstallationStatus.cancelled] - ) - } - processNextInstallations() - } - - /// Get the current status of an installation - func getInstallationStatus(packageName: String) -> PackageInstallationStatus { - return installationStatus[packageName] ?? .notQueued - } - - /// Cleans up installation status by removing completed or failed installations - func cleanUpInstallationStatus() { - let statusKeys = installationStatus.keys.map { $0 } - for packageName in statusKeys { - if let status = installationStatus[packageName] { - switch status { - case .installed, .failed, .cancelled: - installationStatus.removeValue(forKey: packageName) - case .queued, .installing, .notQueued: - break - } - } - } - - // If an item is in runningInstallations but not in an active state in the status dictionary, - // it might be a stale reference - let currentRunning = runningInstallations.map { $0 } - for packageName in currentRunning { - let status = installationStatus[packageName] - if status != .installing { - runningInstallations.remove(packageName) - } - } - - // Check for orphaned queue items - installationQueue = installationQueue.filter { item, _ in - return installationStatus[item.name] == .queued - } - } -} - -/// Status of a package installation -enum PackageInstallationStatus: Equatable { - case notQueued - case queued - case installing - case installed - case failed(Error) - case cancelled - - static func == (lhs: PackageInstallationStatus, rhs: PackageInstallationStatus) -> Bool { - switch (lhs, rhs) { - case (.notQueued, .notQueued): - return true - case (.queued, .queued): - return true - case (.installing, .installing): - return true - case (.installed, .installed): - return true - case (.cancelled, .cancelled): - return true - case (.failed, .failed): - return true - default: - return false - } - } -} - -extension Notification.Name { - static let installationStatusChanged = Notification.Name("installationStatusChanged") -} diff --git a/CodeEdit/Features/LSP/Registry/InstallationMethod.swift b/CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift similarity index 51% rename from CodeEdit/Features/LSP/Registry/InstallationMethod.swift rename to CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift index 0ef71be162..87c5c0d2bd 100644 --- a/CodeEdit/Features/LSP/Registry/InstallationMethod.swift +++ b/CodeEdit/Features/LSP/Registry/Model/InstallationMethod.swift @@ -50,4 +50,42 @@ enum InstallationMethod: Equatable { return nil } } + + func packageManager(installPath: URL) -> PackageManagerProtocol? { + switch packageManagerType { + case .npm: + return NPMPackageManager(installationDirectory: installPath) + case .cargo: + return CargoPackageManager(installationDirectory: installPath) + case .pip: + return PipPackageManager(installationDirectory: installPath) + case .golang: + return GolangPackageManager(installationDirectory: installPath) + case .github, .sourceBuild: + return GithubPackageManager(installationDirectory: installPath) + case .nuget, .opam, .gem, .composer: + // TODO: IMPLEMENT OTHER PACKAGE MANAGERS + return nil + default: + return nil + } + } + + var installerDescription: String { + guard let packageManagerType else { return "Unknown" } + switch packageManagerType { + case .npm, .cargo, .golang, .pip, .sourceBuild, .github: + return packageManagerType.userDescription + case .nuget, .opam, .gem, .composer: + return "(Unsupported) \(packageManagerType.userDescription)" + } + } + + var packageDescription: String? { + guard let packageName else { return nil } + if let version { + return "\(packageName)@\(version)" + } + return packageName + } } diff --git a/CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift b/CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift new file mode 100644 index 0000000000..28ebacee34 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/Model/PackageManagerType.swift @@ -0,0 +1,55 @@ +// +// PackageManagerType.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +/// Package manager types supported by the system +enum PackageManagerType: String, Codable { + /// JavaScript + case npm + /// Rust + case cargo + /// Go + case golang + /// Python + case pip + /// Ruby + case gem + /// C# + case nuget + /// OCaml + case opam + /// PHP + case composer + /// Building from source + case sourceBuild + /// Binary download + case github + + var userDescription: String { + switch self { + case .npm: + "NPM" + case .cargo: + "Cargo" + case .golang: + "Go" + case .pip: + "Pip" + case .gem: + "Gem" + case .nuget: + "Nuget" + case .opam: + "Opam" + case .composer: + "Composer" + case .sourceBuild: + "Build From Source" + case .github: + "Download From GitHub" + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/Model/PackageSource.swift b/CodeEdit/Features/LSP/Registry/Model/PackageSource.swift new file mode 100644 index 0000000000..0df959fb80 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/Model/PackageSource.swift @@ -0,0 +1,52 @@ +// +// PackageSource.swift +// CodeEdit +// +// Created by Khan Winter on 8/18/25. +// + +/// Generic package source information that applies to all installation methods. +/// Takes all the necessary information from `RegistryItem`. +struct PackageSource: Equatable, Codable { + /// The raw source ID string from the registry + let sourceId: String + /// The type of the package manager + let type: PackageManagerType + /// Package name + let pkgName: String + /// The name in the registry.json file. Used for the folder name when saved. + let entryName: String + /// Package version + let version: String + /// URL for repository or download link + let repositoryUrl: String? + /// Git reference type if this is a git based package + let gitReference: GitReference? + /// Additional possible options + var options: [String: String] + + init( + sourceId: String, + type: PackageManagerType, + pkgName: String, + entryName: String, + version: String, + repositoryUrl: String? = nil, + gitReference: GitReference? = nil, + options: [String: String] = [:] + ) { + self.sourceId = sourceId + self.type = type + self.pkgName = pkgName + self.entryName = entryName + self.version = version + self.repositoryUrl = repositoryUrl + self.gitReference = gitReference + self.options = options + } + + enum GitReference: Equatable, Codable { + case tag(String) + case revision(String) + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift similarity index 88% rename from CodeEdit/Features/LSP/Registry/RegistryPackage.swift rename to CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift index f610d349f6..5a1b3e8261 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift +++ b/CodeEdit/Features/LSP/Registry/Model/RegistryItem+Source.swift @@ -1,23 +1,11 @@ // -// RegistryPackage.swift +// RegistryItem+Source.swift // CodeEdit // -// Created by Abe Malla on 1/29/25. +// Created by Khan Winter on 8/15/25. // -import Foundation - -/// A `RegistryItem` represents an entry in the Registry that saves language servers, DAPs, linters and formatters. -struct RegistryItem: Codable { - let name: String - let description: String - let homepage: String - let licenses: [String] - let languages: [String] - let categories: [String] - let source: Source - let bin: [String: String]? - +extension RegistryItem { struct Source: Codable { let id: String let asset: AssetContainer? @@ -195,12 +183,12 @@ struct RegistryItem: Codable { case .multiple(let values): #if arch(arm64) return values.contains("darwin") || - values.contains("darwin_arm64") || - values.contains("unix") + values.contains("darwin_arm64") || + values.contains("unix") #else return values.contains("darwin") || - values.contains("darwin_x64") || - values.contains("unix") + values.contains("darwin_x64") || + values.contains("unix") #endif } } @@ -244,14 +232,4 @@ struct RegistryItem: Codable { let asset: AssetContainer? } } - - /// Serializes back to JSON format - func toDictionary() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - let jsonObject = try JSONSerialization.jsonObject(with: data) - guard let dictionary = jsonObject as? [String: Any] else { - throw NSError(domain: "ConversionError", code: 1) - } - return dictionary - } } diff --git a/CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift b/CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift new file mode 100644 index 0000000000..763c5b2e80 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/Model/RegistryItem.swift @@ -0,0 +1,84 @@ +// +// RegistryItem.swift +// CodeEdit +// +// Created by Abe Malla on 1/29/25. +// + +import Foundation + +/// A `RegistryItem` represents an entry in the Registry that saves language servers, DAPs, linters and formatters. +struct RegistryItem: Codable { + let name: String + let description: String + let homepage: String + let licenses: [String] + let languages: [String] + let categories: [String] + let source: Source + let bin: [String: String]? + + var sanitizedName: String { + name.replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word -> String in + let str = String(word).lowercased() + // Check for special cases + if str == "ls" || str == "lsp" || str == "ci" || str == "cli" { + return str.uppercased() + } + return str.capitalized + } + .joined(separator: " ") + } + + var sanitizedDescription: String { + description.replacingOccurrences(of: "\n", with: " ") + } + + var homepageURL: URL? { + URL(string: homepage) + } + + /// A pretty version of the homepage URL. + /// Removes the schema (eg https) and leaves the path and domain. + var homepagePretty: String { + guard let homepageURL else { return homepage } + return (homepageURL.host(percentEncoded: false) ?? "") + homepageURL.path(percentEncoded: false) + } + + /// The method for installation, parsed from this item's ``source-swift.property`` parameter. + var installMethod: InstallationMethod? { + let sourceId = source.id + if sourceId.hasPrefix("pkg:cargo/") { + return PackageSourceParser.parseCargoPackage(self) + } else if sourceId.hasPrefix("pkg:npm/") { + return PackageSourceParser.parseNpmPackage(self) + } else if sourceId.hasPrefix("pkg:pypi/") { + return PackageSourceParser.parsePythonPackage(self) + } else if sourceId.hasPrefix("pkg:gem/") { + return PackageSourceParser.parseRubyGem(self) + } else if sourceId.hasPrefix("pkg:golang/") { + return PackageSourceParser.parseGolangPackage(self) + } else if sourceId.hasPrefix("pkg:github/") { + return PackageSourceParser.parseGithubPackage(self) + } else { + return nil + } + } + + /// Serializes back to JSON format + func toDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + let jsonObject = try JSONSerialization.jsonObject(with: data) + guard let dictionary = jsonObject as? [String: Any] else { + throw NSError(domain: "ConversionError", code: 1) + } + return dictionary + } +} + +extension RegistryItem: FuzzySearchable { + var searchableString: String { name } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerError.swift b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift deleted file mode 100644 index fd92c26308..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagerError.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// PackageManagerError.swift -// CodeEdit -// -// Created by Abe Malla on 5/12/25. -// - -enum PackageManagerError: Error { - case packageManagerNotInstalled - case initializationFailed(String) - case installationFailed(String) - case invalidConfiguration -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift index 1077cff1b2..c7398e0edc 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -7,90 +7,14 @@ import Foundation +/// The protocol each package manager conforms to for creating ``PackageManagerInstallOperation``s. protocol PackageManagerProtocol { var shellClient: ShellClient { get } - /// Performs any initialization steps for installing a package, such as creating the directory - /// and virtual environments. - func initialize(in packagePath: URL) async throws /// Calls the shell commands to install a package - func install(method installationMethod: InstallationMethod) async throws + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] /// Gets the location of the binary that was installed func getBinaryPath(for package: String) -> String /// Checks if the shell commands for the package manager are available or not - func isInstalled() async -> Bool -} - -extension PackageManagerProtocol { - /// Creates the directory for the language server to be installed in - func createDirectoryStructure(for packagePath: URL) throws { - let decodedPath = packagePath.path.removingPercentEncoding ?? packagePath.path - if !FileManager.default.fileExists(atPath: decodedPath) { - try FileManager.default.createDirectory( - at: packagePath, - withIntermediateDirectories: true, - attributes: nil - ) - } - } - - /// Executes commands in the specified directory - func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { - return try await runCommand("cd \"\(packagePath)\" && \(args.joined(separator: " "))") - } - - /// Runs a shell command and returns output - func runCommand(_ command: String) async throws -> [String] { - var output: [String] = [] - for try await line in shellClient.runAsync(command) { - output.append(line) - } - return output - } -} - -/// Generic package source information that applies to all installation methods. -/// Takes all the necessary information from `RegistryItem`. -struct PackageSource: Equatable, Codable { - /// The raw source ID string from the registry - let sourceId: String - /// The type of the package manager - let type: PackageManagerType - /// Package name - let pkgName: String - /// The name in the registry.json file. Used for the folder name when saved. - let entryName: String - /// Package version - let version: String - /// URL for repository or download link - let repositoryUrl: String? - /// Git reference type if this is a git based package - let gitReference: GitReference? - /// Additional possible options - var options: [String: String] - - init( - sourceId: String, - type: PackageManagerType, - pkgName: String, - entryName: String, - version: String, - repositoryUrl: String? = nil, - gitReference: GitReference? = nil, - options: [String: String] = [:] - ) { - self.sourceId = sourceId - self.type = type - self.pkgName = pkgName - self.entryName = entryName - self.version = version - self.repositoryUrl = repositoryUrl - self.gitReference = gitReference - self.options = options - } - - enum GitReference: Equatable, Codable { - case tag(String) - case revision(String) - } + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerType.swift b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift deleted file mode 100644 index 2a3982f128..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagerType.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// PackageManagerType.swift -// CodeEdit -// -// Created by Abe Malla on 5/12/25. -// - -/// Package manager types supported by the system -enum PackageManagerType: String, Codable { - /// JavaScript - case npm - /// Rust - case cargo - /// Go - case golang - /// Python - case pip - /// Ruby - case gem - /// C# - case nuget - /// OCaml - case opam - /// PHP - case composer - /// Building from source - case sourceBuild - /// Binary download - case github -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift deleted file mode 100644 index c91a8d1a73..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// GithubPackageManager.swift -// LSPInstallTest -// -// Created by Abe Malla on 3/10/25. -// - -import Foundation - -final class GithubPackageManager: PackageManagerProtocol { - private let installationDirectory: URL - - let shellClient: ShellClient - - init(installationDirectory: URL) { - self.installationDirectory = installationDirectory - self.shellClient = .live() - } - - func initialize(in packagePath: URL) async throws { - guard await isInstalled() else { - throw PackageManagerError.packageManagerNotInstalled - } - - do { - try createDirectoryStructure(for: packagePath) - } catch { - throw PackageManagerError.initializationFailed(error.localizedDescription) - } - } - - func install(method: InstallationMethod) async throws { - switch method { - case let .binaryDownload(source, url): - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) - try await downloadBinary(source, url) - - case let .sourceBuild(source, command): - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) - try await installFromSource(source, command) - - case .standardPackage, .unknown: - throw PackageManagerError.invalidConfiguration - } - } - - func getBinaryPath(for package: String) -> String { - return installationDirectory.appending(path: package).appending(path: "bin").path - } - - func isInstalled() async -> Bool { - do { - let versionOutput = try await runCommand("git --version") - let output = versionOutput.reduce(into: "") { - $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) - } - return output.contains("git version") - } catch { - return false - } - } - - private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - throw RegistryManagerError.downloadFailed( - url: url, - error: NSError(domain: "HTTP error", code: (response as? HTTPURLResponse)?.statusCode ?? -1) - ) - } - - let fileName = url.lastPathComponent - let downloadPath = installationDirectory.appending(path: source.entryName) - let packagePath = downloadPath.appending(path: fileName) - - do { - try data.write(to: packagePath, options: .atomic) - } catch { - throw RegistryManagerError.downloadFailed( - url: url, - error: error - ) - } - - if !FileManager.default.fileExists(atPath: packagePath.path) { - throw RegistryManagerError.downloadFailed( - url: url, - error: NSError(domain: "Could not download package", code: -1) - ) - } - - if fileName.hasSuffix(".tar") || fileName.hasSuffix(".zip") { - try FileManager.default.unzipItem(at: packagePath, to: downloadPath) - } - } - - private func installFromSource(_ source: PackageSource, _ command: String) async throws { - let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) - do { - guard let repoURL = source.repositoryUrl else { - throw PackageManagerError.invalidConfiguration - } - - _ = try await executeInDirectory(in: installPath.path, ["git", "clone", repoURL]) - let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) - _ = try await executeInDirectory(in: repoPath.path, [command]) - } catch { - throw PackageManagerError.installationFailed("Source build failed.") - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift new file mode 100644 index 0000000000..5f9eccfe09 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/InstallStepConfirmation.swift @@ -0,0 +1,11 @@ +// +// InstallStepConfirmation.swift +// CodeEdit +// +// Created by Khan Winter on 8/8/25. +// + +enum InstallStepConfirmation { + case none + case required(message: String) +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift new file mode 100644 index 0000000000..03e866adbb --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallOperation.swift @@ -0,0 +1,168 @@ +// +// PackageManagerInstallOperation.swift +// CodeEdit +// +// Created by Khan Winter on 8/8/25. +// + +import Foundation +import Combine + +/// An executable install operation for installing a ``RegistryItem``. +/// +/// Has a single entry point, ``run()``, which kicks off the operation. UI can observe properties like the ``error``, +/// ``runningState-swift.property``, or ``currentStep`` to monitor progress. +/// +/// If a step requires confirmation, the ``waitingForConfirmation`` value will be filled. +@MainActor +final class PackageManagerInstallOperation: ObservableObject, Identifiable { + enum RunningState { + case none + case running + case complete + } + + struct OutputItem: Identifiable, Equatable { + let id: UUID = UUID() + let isStepDivider: Bool + let outputIdx: Int? + let contents: String + + init(outputIdx: Int? = nil, isStepDivider: Bool = false, contents: String) { + self.isStepDivider = isStepDivider + self.outputIdx = outputIdx + self.contents = contents + } + } + + nonisolated var id: String { package.name } + + let package: RegistryItem + let steps: [PackageManagerInstallStep] + + /// The step the operation is currently executing or stopped at. + var currentStep: PackageManagerInstallStep? { + steps[safe: currentStepIdx] + } + + /// The current state of the operation. + var runningState: RunningState { + if operationTask != nil { + return .running + } else if error != nil || currentStepIdx == steps.count { + return .complete + } else { + return .none + } + } + + @Published var accumulatedOutput: [OutputItem] = [] + @Published var currentStepIdx: Int = 0 + @Published var error: Error? + @Published var progress: Progress + + /// If non-nil, indicates that this operation has halted and requires confirmation. + @Published public private(set) var waitingForConfirmation: String? + + private let shellClient: ShellClient = .live() + private var operationTask: Task? + private var confirmationContinuation: CheckedContinuation? + private var outputIdx = 0 + + /// Create a new operation using a list of steps and a package description. + /// See ``PackageManagerProtocol`` for + /// - Parameters: + /// - package: The package to install. + /// - steps: The steps that make up the operation. + init(package: RegistryItem, steps: [PackageManagerInstallStep]) { + self.package = package + self.steps = steps + self.progress = Progress(totalUnitCount: Int64(steps.count)) + } + + func run() async throws { + guard operationTask == nil else { return } + operationTask = Task { + defer { operationTask = nil } + try await runNext() + } + try await operationTask?.value + } + + func cancel() { + operationTask?.cancel() + operationTask = nil + } + + /// Called by UI to confirm continuing to the next step + func confirmCurrentStep() { + waitingForConfirmation = nil + confirmationContinuation?.resume() + confirmationContinuation = nil + } + + private func waitForConfirmation(message: String) async { + waitingForConfirmation = message + await withCheckedContinuation { [weak self] (continuation: CheckedContinuation) in + self?.confirmationContinuation = continuation + } + } + + private func runNext() async throws { + guard currentStepIdx < steps.count, error == nil else { + return + } + + let task = steps[currentStepIdx] + + switch task.confirmation { + case .required(let message): + await waitForConfirmation(message: message) + case .none: + break + } + + let model = PackageManagerProgressModel(shellClient: shellClient) + progress.addChild(model.progress, withPendingUnitCount: 1) + + try Task.checkCancellation() + accumulatedOutput.append(OutputItem(isStepDivider: true, contents: "Step \(currentStepIdx + 1): \(task.name)")) + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await outputItem in model.outputStream { + await MainActor.run { + switch outputItem { + case .status(let string): + self.outputIdx += 1 + self.accumulatedOutput.append(OutputItem(outputIdx: self.outputIdx, contents: string)) + case .output(let string): + self.accumulatedOutput.append(OutputItem(contents: string)) + } + } + } + } + group.addTask { + do { + try await task.handler(model) + } catch { + await MainActor.run { + self.error = error + } + } + await MainActor.run { + model.finish() + } + } + } + + self.currentStepIdx += 1 + + try Task.checkCancellation() + if let error { + progress.cancel() + throw error + } + try await runNext() + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift new file mode 100644 index 0000000000..7c57106462 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerInstallStep.swift @@ -0,0 +1,14 @@ +// +// PackageManagerInstallStep.swift +// CodeEdit +// +// Created by Khan Winter on 8/18/25. +// + +/// Represents a single executable step in a package install. +struct PackageManagerInstallStep: Identifiable { + var id: String { name } + let name: String + let confirmation: InstallStepConfirmation + let handler: (_ model: PackageManagerProgressModel) async throws -> Void +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift new file mode 100644 index 0000000000..7451ef22e8 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Install/PackageManagerProgressModel.swift @@ -0,0 +1,77 @@ +// +// PackageManagerProgressModel.swift +// CodeEdit +// +// Created by Khan Winter on 8/8/25. +// + +import Foundation +import Combine + +/// This model is injected into each ``PackageManagerInstallStep`` when executing a ``PackageManagerInstallOperation``. +/// A single model is used for each step. Output is collected by the ``PackageManagerInstallOperation``. +/// +/// Yields output into an async stream, and provides common helper methods for exec-ing commands, creating +/// directories, etc. +@MainActor +final class PackageManagerProgressModel: ObservableObject { + enum OutputItem { + case status(String) + case output(String) + } + + let outputStream: AsyncStream + @Published var progress: Progress + + private let shellClient: ShellClient + private let outputContinuation: AsyncStream.Continuation + + init(shellClient: ShellClient) { + self.shellClient = shellClient + self.progress = Progress(totalUnitCount: 1) + (outputStream, outputContinuation) = AsyncStream.makeStream() + } + + func status(_ string: String) { + outputContinuation.yield(.status(string)) + } + + /// Creates the directory for the language server to be installed in + func createDirectoryStructure(for packagePath: URL) throws { + let decodedPath = packagePath.path(percentEncoded: false) + if FileManager.default.fileExists(atPath: decodedPath) { + status("Removing existing installation.") + try FileManager.default.removeItem(at: packagePath) + } + + status("Creating directory: \(decodedPath)") + try FileManager.default.createDirectory( + at: packagePath, + withIntermediateDirectories: true, + attributes: nil + ) + } + + /// Executes commands in the specified directory + @discardableResult + func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { + return try await runCommand("cd \"\(packagePath)\" && \(args.joined(separator: " "))") + } + + /// Runs a shell command and returns output + @discardableResult + func runCommand(_ command: String) async throws -> [String] { + var output: [String] = [] + status("\(command)") + for try await line in shellClient.runAsync(command) { + output.append(line) + outputContinuation.yield(.output(line)) + } + return output + } + + func finish() { + outputContinuation.finish() + progress.completedUnitCount = progress.totalUnitCount + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift deleted file mode 100644 index e74470bb28..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// NPMPackageManager.swift -// CodeEdit -// -// Created by Abe Malla on 2/2/25. -// - -import Foundation - -final class NPMPackageManager: PackageManagerProtocol { - private let installationDirectory: URL - - let shellClient: ShellClient - - init(installationDirectory: URL) { - self.installationDirectory = installationDirectory - self.shellClient = .live() - } - - /// Initializes the npm project if not already initialized - func initialize(in packagePath: URL) async throws { - guard await isInstalled() else { - throw PackageManagerError.packageManagerNotInstalled - } - - do { - // Clean existing files - let pkgJson = packagePath.appending(path: "package.json") - if FileManager.default.fileExists(atPath: pkgJson.path) { - try FileManager.default.removeItem(at: pkgJson) - } - let pkgLockJson = packagePath.appending(path: "package-lock.json") - if FileManager.default.fileExists(atPath: pkgLockJson.path) { - try FileManager.default.removeItem(at: pkgLockJson) - } - - // Init npm directory with .npmrc file - try createDirectoryStructure(for: packagePath) - _ = try await executeInDirectory( - in: packagePath.path, ["npm init --yes --scope=codeedit"] - ) - - let npmrcPath = packagePath.appending(path: ".npmrc") - if !FileManager.default.fileExists(atPath: npmrcPath.path) { - try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) - } - } catch { - throw PackageManagerError.initializationFailed(error.localizedDescription) - } - } - - /// Install a package using the new installation method - func install(method: InstallationMethod) async throws { - guard case .standardPackage(let source) = method else { - throw PackageManagerError.invalidConfiguration - } - - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) - - do { - var installArgs = ["npm", "install", "\(source.pkgName)@\(source.version)"] - if let dev = source.options["dev"], dev.lowercased() == "true" { - installArgs.append("--save-dev") - } - if let extraPackages = source.options["extraPackages"]?.split(separator: ",") { - for pkg in extraPackages { - installArgs.append(String(pkg).trimmingCharacters(in: .whitespacesAndNewlines)) - } - } - - _ = try await executeInDirectory(in: packagePath.path, installArgs) - try verifyInstallation(folderName: source.entryName, package: source.pkgName, version: source.version) - } catch { - let nodeModulesPath = packagePath.appending(path: "node_modules").path - try? FileManager.default.removeItem(atPath: nodeModulesPath) - throw error - } - } - - /// Get the path to the binary - func getBinaryPath(for package: String) -> String { - let binDirectory = installationDirectory - .appending(path: package) - .appending(path: "node_modules") - .appending(path: ".bin") - return binDirectory.appending(path: package).path - } - - /// Checks if npm is installed - func isInstalled() async -> Bool { - do { - let versionOutput = try await runCommand("npm --version") - let versionPattern = #"^\d+\.\d+\.\d+$"# - let output = versionOutput.reduce(into: "") { - $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) - } - return output.range(of: versionPattern, options: .regularExpression) != nil - } catch { - return false - } - } - - /// Verify the installation was successful - private func verifyInstallation(folderName: String, package: String, version: String) throws { - let packagePath = installationDirectory.appending(path: folderName) - let packageJsonPath = packagePath.appending(path: "package.json").path - - // Verify package.json contains the installed package - guard let packageJsonData = FileManager.default.contents(atPath: packageJsonPath), - let packageJson = try? JSONSerialization.jsonObject(with: packageJsonData, options: []), - let packageDict = packageJson as? [String: Any], - let dependencies = packageDict["dependencies"] as? [String: String], - let installedVersion = dependencies[package] else { - throw PackageManagerError.installationFailed("Package not found in package.json") - } - - // Verify installed version matches requested version - let normalizedInstalledVersion = installedVersion.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) - let normalizedRequestedVersion = version.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) - if normalizedInstalledVersion != normalizedRequestedVersion && - !installedVersion.contains(normalizedRequestedVersion) { - throw PackageManagerError.installationFailed( - "Version mismatch: Expected \(version), but found \(installedVersion)" - ) - } - - // Verify the package exists in node_modules - let packageDirectory = packagePath - .appending(path: "node_modules") - .appending(path: package) - guard FileManager.default.fileExists(atPath: packageDirectory.path) else { - throw PackageManagerError.installationFailed("Package not found in node_modules") - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift deleted file mode 100644 index 4eb5ff2624..0000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// PipPackageManager.swift -// CodeEdit -// -// Created by Abe Malla on 2/3/25. -// - -import Foundation - -final class PipPackageManager: PackageManagerProtocol { - private let installationDirectory: URL - - let shellClient: ShellClient - - init(installationDirectory: URL) { - self.installationDirectory = installationDirectory - self.shellClient = .live() - } - - func initialize(in packagePath: URL) async throws { - guard await isInstalled() else { - throw PackageManagerError.packageManagerNotInstalled - } - - do { - try createDirectoryStructure(for: packagePath) - _ = try await executeInDirectory( - in: packagePath.path, ["python -m venv venv"] - ) - - let requirementsPath = packagePath.appending(path: "requirements.txt") - if !FileManager.default.fileExists(atPath: requirementsPath.path) { - try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) - } - } catch { - throw PackageManagerError.initializationFailed(error.localizedDescription) - } - } - - func install(method: InstallationMethod) async throws { - guard case .standardPackage(let source) = method else { - throw PackageManagerError.invalidConfiguration - } - - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) - - do { - let pipCommand = getPipCommand(in: packagePath) - var installArgs = [pipCommand, "install"] - - if source.version.lowercased() != "latest" { - installArgs.append("\(source.pkgName)==\(source.version)") - } else { - installArgs.append(source.pkgName) - } - - let extras = source.options["extra"] - if let extras { - if let lastIndex = installArgs.indices.last { - installArgs[lastIndex] += "[\(extras)]" - } - } - - _ = try await executeInDirectory(in: packagePath.path, installArgs) - try await updateRequirements(packagePath: packagePath) - try await verifyInstallation(packagePath: packagePath, package: source.pkgName) - } catch { - throw error - } - } - - /// Get the binary path for a Python package - func getBinaryPath(for package: String) -> String { - let packagePath = installationDirectory.appending(path: package) - let customBinPath = packagePath.appending(path: "bin").appending(path: package).path - if FileManager.default.fileExists(atPath: customBinPath) { - return customBinPath - } - return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path - } - - func isInstalled() async -> Bool { - let pipCommands = ["pip3 --version", "python3 -m pip --version"] - for command in pipCommands { - do { - let versionOutput = try await runCommand(command) - let versionPattern = #"pip \d+\.\d+"# - let output = versionOutput.reduce(into: "") { - $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) - } - if output.range(of: versionPattern, options: .regularExpression) != nil { - return true - } - } catch { - continue - } - } - return false - } - - // MARK: - Helper methods - - private func getPipCommand(in packagePath: URL) -> String { - let venvPip = "venv/bin/pip" - return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) - ? venvPip - : "python -m pip" - } - - /// Update the requirements.txt file with the installed package and extras - private func updateRequirements(packagePath: URL) async throws { - let pipCommand = getPipCommand(in: packagePath) - let requirementsPath = packagePath.appending(path: "requirements.txt") - - let freezeOutput = try await executeInDirectory( - in: packagePath.path, - ["\(pipCommand)", "freeze"] - ) - - let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" - try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) - } - - private func verifyInstallation(packagePath: URL, package: String) async throws { - let pipCommand = getPipCommand(in: packagePath) - let output = try await executeInDirectory( - in: packagePath.path, ["\(pipCommand)", "list", "--format=freeze"] - ) - - // Normalize package names for comparison - let normalizedPackageHyphen = package.replacingOccurrences(of: "_", with: "-").lowercased() - let normalizedPackageUnderscore = package.replacingOccurrences(of: "-", with: "_").lowercased() - - // Check if the package name appears in requirements.txt - let installedPackages = output.map { line in - line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) - } - let packageFound = installedPackages.contains { installedPackage in - installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore - } - - guard packageFound else { - throw PackageManagerError.installationFailed("Package \(package) not found in pip list") - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift similarity index 51% rename from CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift rename to CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift index f03ee6a2d2..8eef86c149 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/CargoPackageManager.swift @@ -17,27 +17,52 @@ final class CargoPackageManager: PackageManagerProtocol { self.shellClient = .live() } - func initialize(in packagePath: URL) async throws { - do { - try createDirectoryStructure(for: packagePath) - } catch { - throw PackageManagerError.initializationFailed(error.localizedDescription) + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { + guard case .standardPackage(let source) = installationMethod else { + throw PackageManagerError.invalidConfiguration } + let packagePath = installationDirectory.appending(path: source.entryName) + return [ + initialize(in: packagePath), + runCargoInstall(source, in: packagePath) + ] + } - guard await isInstalled() else { - throw PackageManagerError.packageManagerNotInstalled + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "", + confirmation: .none + ) { model in + let versionOutput = try await model.runCommand("cargo --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard output.starts(with: "cargo") else { + throw PackageManagerError.packageManagerNotInstalled + } } } - func install(method: InstallationMethod) async throws { - guard case .standardPackage(let source) = method else { - throw PackageManagerError.invalidConfiguration + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + func initialize(in packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep(name: "Initialize Directory Structure", confirmation: .none) { model in + try await model.createDirectoryStructure(for: packagePath) } + } - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) + func runCargoInstall(_ source: PackageSource, in packagePath: URL) -> PackageManagerInstallStep { + let qualifiedPackageName = "\(source.pkgName)@\(source.version)" - do { + return PackageManagerInstallStep( + name: "Install Package Using cargo", + confirmation: .required( + message: "This requires the cargo package \(qualifiedPackageName)." + + "\nAllow CodeEdit to install this package?" + ) + ) { model in var cargoArgs = ["cargo", "install", "--root", "."] // If this is a git-based package @@ -50,7 +75,7 @@ final class CargoPackageManager: PackageManagerProtocol { cargoArgs.append(contentsOf: ["--rev", rev]) } } else { - cargoArgs.append("\(source.pkgName)@\(source.version)") + cargoArgs.append(qualifiedPackageName) } if let features = source.options["features"] { @@ -60,25 +85,7 @@ final class CargoPackageManager: PackageManagerProtocol { cargoArgs.append("--locked") } - _ = try await executeInDirectory(in: packagePath.path, cargoArgs) - } catch { - throw error - } - } - - func getBinaryPath(for package: String) -> String { - return installationDirectory.appending(path: package).appending(path: "bin").path - } - - func isInstalled() async -> Bool { - do { - let versionOutput = try await runCommand("cargo --version") - let output = versionOutput.reduce(into: "") { - $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) - } - return output.starts(with: "cargo") - } catch { - return false + try await model.executeInDirectory(in: packagePath.path(percentEncoded: false), cargoArgs) } } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift new file mode 100644 index 0000000000..de06433c1d --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GithubPackageManager.swift @@ -0,0 +1,238 @@ +// +// GithubPackageManager.swift +// LSPInstallTest +// +// Created by Abe Malla on 3/10/25. +// + +import Foundation + +final class GithubPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + // MARK: - PackageManagerProtocol + + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { + switch installationMethod { + case let .binaryDownload(source, url): + let packagePath = installationDirectory.appending(path: source.entryName) + return [ + initialize(in: packagePath), + downloadBinary(source, url: url, installDir: installationDirectory), + decompressBinary(source, url: url, installDir: installationDirectory) + ] + case let .sourceBuild(source, command): + let packagePath = installationDirectory.appending(path: source.entryName) + return [ + initialize(in: packagePath), + try gitClone(source, installDir: installationDirectory), + installFromSource(source, installDir: installationDirectory, command: command) + ] + case .standardPackage, .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { + switch installationMethod { + case .binaryDownload: + PackageManagerInstallStep( + name: "", + confirmation: .none, + handler: { _ in } + ) + case .sourceBuild: + PackageManagerInstallStep( + name: "", + confirmation: .required( + message: "This package requires git to install. Allow CodeEdit to run git commands?" + ) + ) { model in + let versionOutput = try await model.runCommand("git --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard output.contains("git version") else { + throw PackageManagerError.packageManagerNotInstalled + } + } + case .standardPackage, .unknown: + PackageManagerInstallStep( + name: "", + confirmation: .none, + handler: { _ in throw PackageManagerError.invalidConfiguration } + ) + } + } + + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + // MARK: - Initialize + + func initialize(in packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Initialize Directory Structure", + confirmation: .none + ) { model in + do { + try await model.createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + } + + // MARK: - Download Binary + + private func downloadBinary( + _ source: PackageSource, + url: URL, + installDir installationDirectory: URL + ) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Download Binary Executable", + confirmation: .none + ) { model in + do { + await model.status("Downloading \(url)") + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 120.0) + // TODO: Progress Updates + let (tempURL, response) = try await URLSession.shared.download(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "HTTP error", code: (response as? HTTPURLResponse)?.statusCode ?? -1) + ) + } + + let fileName = url.lastPathComponent + let downloadPath = installationDirectory.appending(path: source.entryName) + let packagePath = downloadPath.appending(path: fileName) + if FileManager.default.fileExists(atPath: packagePath.path()) { + try FileManager.default.removeItem(at: packagePath) + } + + try FileManager.default.moveItem(at: tempURL, to: packagePath) + + if !FileManager.default.fileExists(atPath: packagePath.path) { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "Could not download package", code: -1) + ) + } + } catch { + if error is RegistryManagerError { + // Keep error info for known errors thrown here. + throw error + } + throw RegistryManagerError.downloadFailed( + url: url, + error: error + ) + } + + } + } + + // MARK: - Decompress Binary + + private func decompressBinary( + _ source: PackageSource, + url: URL, + installDir installationDirectory: URL + ) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Decompress Binary Executable", + confirmation: .none, + ) { model in + let fileName = url.lastPathComponent + let downloadPath = installationDirectory.appending(path: source.entryName) + let packagePath = downloadPath.appending(path: fileName) + + if packagePath.pathExtension == "tar" || packagePath.pathExtension == ".zip" { + await model.status("Decompressing \(fileName)") + try await FileManager.default.unzipItem(at: packagePath, to: downloadPath, progress: model.progress) + if FileManager.default.fileExists(atPath: packagePath.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: packagePath) + } + await model.status("Decompressed to '\(downloadPath.path(percentEncoded: false))'") + } else if packagePath.lastPathComponent.hasSuffix(".tar.gz") { + await model.status("Decompressing \(fileName) using `tar`") + _ = try await model.executeInDirectory( + in: packagePath.deletingLastPathComponent().path(percentEncoded: false), + [ + "tar", + "-xzf", + packagePath.path(percentEncoded: false).escapedDirectory(), + ] + ) + } else if packagePath.pathExtension == "gz" { + await model.status("Decompressing \(fileName) using `gunzip`") + _ = try await model.executeInDirectory( + in: packagePath.deletingLastPathComponent().path(percentEncoded: false), + [ + "gunzip", + "-f", + packagePath.path(percentEncoded: false).escapedDirectory(), + ] + ) + } + + let executablePath = downloadPath.appending(path: url.deletingPathExtension().lastPathComponent) + try FileManager.default.makeExecutable(executablePath) + } + } + + // MARK: - Git Clone + + private func gitClone( + _ source: PackageSource, + installDir installationDirectory: URL + ) throws -> PackageManagerInstallStep { + guard let repoURL = source.repositoryUrl else { + throw PackageManagerError.invalidConfiguration + } + let command = ["git", "clone", repoURL] + + return PackageManagerInstallStep( + name: "Clone with Git", + // swiftlint:disable:next line_length + confirmation: .required(message: "This step will run the following command to clone the package from source control:\n`\(command.joined(separator: " "))`") + ) { model in + let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) + _ = try await model.executeInDirectory(in: installPath.path, command) + } + } + + // MARK: - Install From Source + + private func installFromSource( + _ source: PackageSource, + installDir installationDirectory: URL, + command: String + ) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Install From Source", + confirmation: .required(message: "This step will run the following to finish installing:\n`\(command)`") + ) { model in + do { + let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) + let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) + _ = try await model.executeInDirectory(in: repoPath.path, [command]) + } catch { + throw PackageManagerError.installationFailed("Source build failed.") + } + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift similarity index 61% rename from CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift rename to CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift index e30e02c3d0..574cdd2e39 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/GolangPackageManager.swift @@ -17,42 +17,100 @@ final class GolangPackageManager: PackageManagerProtocol { self.shellClient = .live() } - func initialize(in packagePath: URL) async throws { - guard await isInstalled() else { - throw PackageManagerError.packageManagerNotInstalled + // MARK: - PackageManagerProtocol + + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { + guard case .standardPackage(let source) = installationMethod else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + var steps = [ + initialize(in: packagePath), + runGoInstall(source, packagePath: packagePath) + ] + + if source.options["subpath"] != nil { + steps.append(buildBinary(source, packagePath: packagePath)) + } + + return steps + } + + /// Check if go is installed + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "", + confirmation: .required(message: "This package requires go to install. Allow CodeEdit to run go commands?") + ) { model in + let versionOutput = try await model.runCommand("go version") + let versionPattern = #"go version go\d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard output.range(of: versionPattern, options: .regularExpression) != nil else { + throw PackageManagerError.packageManagerNotInstalled + } } + } - do { - try createDirectoryStructure(for: packagePath) + /// Get the binary path for a Go package + func getBinaryPath(for package: String) -> String { + let binPath = installationDirectory.appending(path: package).appending(path: "bin") + let binaryName = package.components(separatedBy: "/").last ?? package + let specificBinPath = binPath.appending(path: binaryName).path + if FileManager.default.fileExists(atPath: specificBinPath) { + return specificBinPath + } + return binPath.path + } + + // MARK: - Initialize + + func initialize(in packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Initialize Directory Structure", + confirmation: .none + ) { model in + try await model.createDirectoryStructure(for: packagePath) // For Go, we need to set up a proper module structure let goModPath = packagePath.appending(path: "go.mod") if !FileManager.default.fileExists(atPath: goModPath.path) { let moduleName = "codeedit.temp/placeholder" - _ = try await executeInDirectory( + _ = try await model.executeInDirectory( in: packagePath.path, ["go mod init \(moduleName)"] ) } - } catch { - throw PackageManagerError.initializationFailed(error.localizedDescription) } } - func install(method: InstallationMethod) async throws { - guard case .standardPackage(let source) = method else { - throw PackageManagerError.invalidConfiguration - } - - let packagePath = installationDirectory.appending(path: source.entryName) - try await initialize(in: packagePath) - - do { + // MARK: - Install Using Go + + func runGoInstall(_ source: PackageSource, packagePath: URL) -> PackageManagerInstallStep { + let installCommand = getGoInstallCommand(source) + return PackageManagerInstallStep( + name: "Install Package Using go", + confirmation: .required( + message: "This requires installing the go package \(installCommand)." + + "\nAllow CodeEdit to install this package?" + ) + ) { model in let gobinPath = packagePath.appending(path: "bin", directoryHint: .isDirectory).path var goInstallCommand = ["env", "GOBIN=\(gobinPath)", "go", "install"] - goInstallCommand.append(getGoInstallCommand(source)) - _ = try await executeInDirectory(in: packagePath.path, goInstallCommand) + goInstallCommand.append(installCommand) + _ = try await model.executeInDirectory(in: packagePath.path, goInstallCommand) + } + } + // MARK: - Build Binary + + func buildBinary(_ source: PackageSource, packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "Build From Source", + confirmation: .none + ) { model in // If there's a subpath, build the binary if let subpath = source.options["subpath"] { let binPath = packagePath.appending(path: "bin") @@ -61,50 +119,22 @@ final class GolangPackageManager: PackageManagerProtocol { } let binaryName = subpath.components(separatedBy: "/").last ?? - source.pkgName.components(separatedBy: "/").last ?? source.pkgName + source.pkgName.components(separatedBy: "/").last ?? source.pkgName let buildArgs = ["go", "build", "-o", "bin/\(binaryName)"] // If source.pkgName includes the full import path (like github.com/owner/repo) if source.pkgName.contains("/") { - _ = try await executeInDirectory( + _ = try await model.executeInDirectory( in: packagePath.path, buildArgs + ["\(source.pkgName)/\(subpath)"] ) } else { - _ = try await executeInDirectory( + _ = try await model.executeInDirectory( in: packagePath.path, buildArgs + [subpath] ) } - let execPath = packagePath.appending(path: "bin").appending(path: binaryName).path - _ = try await runCommand("chmod +x \"\(execPath)\"") - } - } catch { - try? cleanupFailedInstallation(packagePath: packagePath) - throw PackageManagerError.installationFailed(error.localizedDescription) - } - } - - /// Get the binary path for a Go package - func getBinaryPath(for package: String) -> String { - let binPath = installationDirectory.appending(path: package).appending(path: "bin") - let binaryName = package.components(separatedBy: "/").last ?? package - let specificBinPath = binPath.appending(path: binaryName).path - if FileManager.default.fileExists(atPath: specificBinPath) { - return specificBinPath - } - return binPath.path - } - - /// Check if go is installed - func isInstalled() async -> Bool { - do { - let versionOutput = try await runCommand("go version") - let versionPattern = #"go version go\d+\.\d+"# - let output = versionOutput.reduce(into: "") { - $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + let execPath = packagePath.appending(path: "bin").appending(path: binaryName) + try FileManager.default.makeExecutable(execPath) } - return output.range(of: versionPattern, options: .regularExpression) != nil - } catch { - return false } } @@ -118,18 +148,6 @@ final class GolangPackageManager: PackageManagerProtocol { } } - /// Verify the go.mod file has the expected dependencies - private func verifyGoModDependencies(packagePath: URL, dependencyPath: String) async throws -> Bool { - let output = try await executeInDirectory( - in: packagePath.path, ["go list -m all"] - ) - - // Check if the dependency appears in the module list - return output.contains { line in - line.contains(dependencyPath) - } - } - private func getGoInstallCommand(_ source: PackageSource) -> String { if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { // Check if this is a Git-based package diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift new file mode 100644 index 0000000000..e5988429b3 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/NPMPackageManager.swift @@ -0,0 +1,186 @@ +// +// NPMPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +final class NPMPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + // MARK: - PackageManagerProtocol + + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { + guard case .standardPackage(let source) = installationMethod else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + return [ + initialize(in: packagePath), + runNpmInstall(source, installDir: packagePath), + verifyInstallation(source, installDir: packagePath) + ] + + } + + /// Checks if npm is installed + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { + PackageManagerInstallStep( + name: "", + confirmation: .required( + message: "This package requires npm to install. Allow CodeEdit to run npm commands?" + ) + ) { model in + let versionOutput = try await model.runCommand("npm --version") + let versionPattern = #"^\d+\.\d+\.\d+$"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard output.range(of: versionPattern, options: .regularExpression) != nil else { + throw PackageManagerError.packageManagerNotInstalled + } + } + } + + /// Get the path to the binary + func getBinaryPath(for package: String) -> String { + let binDirectory = installationDirectory + .appending(path: package) + .appending(path: "node_modules") + .appending(path: ".bin") + return binDirectory.appending(path: package).path + } + + // MARK: - Initialize + + /// Initializes the npm project if not already initialized + func initialize(in packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep(name: "Initialize Directory Structure", confirmation: .none) { model in + // Clean existing files + let pkgJson = packagePath.appending(path: "package.json") + if FileManager.default.fileExists(atPath: pkgJson.path) { + try FileManager.default.removeItem(at: pkgJson) + } + let pkgLockJson = packagePath.appending(path: "package-lock.json") + if FileManager.default.fileExists(atPath: pkgLockJson.path) { + try FileManager.default.removeItem(at: pkgLockJson) + } + + // Init npm directory with .npmrc file + try await model.createDirectoryStructure(for: packagePath) + _ = try await model.executeInDirectory( + in: packagePath.path, ["npm init --yes --scope=codeedit"] + ) + + let npmrcPath = packagePath.appending(path: ".npmrc") + if !FileManager.default.fileExists(atPath: npmrcPath.path) { + try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) + } + } + } + + // MARK: - NPM Install + + func runNpmInstall(_ source: PackageSource, installDir installationDirectory: URL) -> PackageManagerInstallStep { + let qualifiedSourceName = "\(source.pkgName)@\(source.version)" + let otherPackages = source.options["extraPackages"]? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? [] + + var packageList = ([qualifiedSourceName] + otherPackages) + + // FIXME: This will break with localization. Use real Foundation APIs for pluralizing lists. + let plural = packageList.count > 1 + if plural, var last = packageList.last { + // Oxford comma + last = "and " + last + packageList[packageList.count - 1] = last + } + let packagesDescription = packageList.joined(separator: ", ") + + let sSuffix = packageList.count > 1 ? "s" : "" + let suffix = plural ? "these packages" : "this package" + + return PackageManagerInstallStep( + name: "Install Package Using npm", + confirmation: .required( + message: "This requires the npm package\(sSuffix) \(packagesDescription)." + + "\nAllow CodeEdit to install \(suffix)?" + ) + ) { model in + do { + var installArgs = ["npm", "install", qualifiedSourceName] + if let dev = source.options["dev"], dev.lowercased() == "true" { + installArgs.append("--save-dev") + } + for extraPackage in otherPackages { + installArgs.append(extraPackage) + } + + _ = try await model.executeInDirectory( + in: installationDirectory.path(percentEncoded: false), + installArgs + ) + } catch { + let nodeModulesPath = installationDirectory.appending(path: "node_modules").path(percentEncoded: false) + try? FileManager.default.removeItem(atPath: nodeModulesPath) + throw error + } + } + } + + // MARK: - Verify + + /// Verify the installation was successful + private func verifyInstallation( + _ source: PackageSource, + installDir packagePath: URL + ) -> PackageManagerInstallStep { + let package = source.pkgName + let version = source.version + + return PackageManagerInstallStep( + name: "Verify Installation", + confirmation: .none + ) { _ in + let packageJsonPath = packagePath.appending(path: "package.json").path + + // Verify package.json contains the installed package + guard let packageJsonData = FileManager.default.contents(atPath: packageJsonPath), + let packageJson = try? JSONSerialization.jsonObject(with: packageJsonData, options: []), + let packageDict = packageJson as? [String: Any], + let dependencies = packageDict["dependencies"] as? [String: String], + let installedVersion = dependencies[package] else { + throw PackageManagerError.installationFailed("Package not found in package.json") + } + + // Verify installed version matches requested version + let normalizedInstalledVersion = installedVersion.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + let normalizedRequestedVersion = version.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + if normalizedInstalledVersion != normalizedRequestedVersion && + !installedVersion.contains(normalizedRequestedVersion) { + throw PackageManagerError.installationFailed( + "Version mismatch: Expected \(version), but found \(installedVersion)" + ) + } + + // Verify the package exists in node_modules + let packageDirectory = packagePath + .appending(path: "node_modules") + .appending(path: package) + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + throw PackageManagerError.installationFailed("Package not found in node_modules") + } + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift new file mode 100644 index 0000000000..b7840e46aa --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/Sources/PipPackageManager.swift @@ -0,0 +1,175 @@ +// +// PipPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class PipPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + // MARK: - PackageManagerProtocol + + func install(method installationMethod: InstallationMethod) throws -> [PackageManagerInstallStep] { + guard case .standardPackage(let source) = installationMethod else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + return [ + initialize(in: packagePath), + runPipInstall(source, in: packagePath), + updateRequirements(in: packagePath), + verifyInstallation(source, in: packagePath) + ] + } + + func isInstalled(method installationMethod: InstallationMethod) -> PackageManagerInstallStep { + PackageManagerInstallStep(name: "", confirmation: .none) { model in + let pipCommands = ["pip3 --version", "python3 -m pip --version"] + var didFindPip = false + for command in pipCommands { + do { + let versionOutput = try await model.runCommand(command) + let versionPattern = #"pip \d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + if output.range(of: versionPattern, options: .regularExpression) != nil { + didFindPip = true + break + } + } catch { + continue + } + } + guard didFindPip else { + throw PackageManagerError.packageManagerNotInstalled + } + } + + } + + /// Get the binary path for a Python package + func getBinaryPath(for package: String) -> String { + let packagePath = installationDirectory.appending(path: package) + let customBinPath = packagePath.appending(path: "bin").appending(path: package).path + if FileManager.default.fileExists(atPath: customBinPath) { + return customBinPath + } + return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path + } + + // MARK: - Initialize + + func initialize(in packagePath: URL) -> PackageManagerInstallStep { + PackageManagerInstallStep(name: "Initialize Directory Structure", confirmation: .none) { model in + try await model.createDirectoryStructure(for: packagePath) + try await model.executeInDirectory(in: packagePath.path(percentEncoded: false), ["python -m venv venv"]) + + let requirementsPath = packagePath.appending(path: "requirements.txt") + if !FileManager.default.fileExists(atPath: requirementsPath.path) { + try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) + } + } + } + + // MARK: - Pip Install + + func runPipInstall(_ source: PackageSource, in packagePath: URL) -> PackageManagerInstallStep { + let pipCommand = getPipCommand(in: packagePath) + return PackageManagerInstallStep( + name: "Install Package Using pip", + confirmation: .required( + message: "This requires the pip package \(source.pkgName)." + + "\nAllow CodeEdit to install this package?" + ) + ) { model in + var installArgs = [pipCommand, "install"] + + if source.version.lowercased() != "latest" { + installArgs.append("\(source.pkgName)==\(source.version)") + } else { + installArgs.append(source.pkgName) + } + + let extras = source.options["extra"] + if let extras { + if let lastIndex = installArgs.indices.last { + installArgs[lastIndex] += "[\(extras)]" + } + } + + try await model.executeInDirectory(in: packagePath.path, installArgs) + } + } + + // MARK: - Update Requirements.txt + + /// Update the requirements.txt file with the installed package and extras + private func updateRequirements(in packagePath: URL) -> PackageManagerInstallStep { + let pipCommand = getPipCommand(in: packagePath) + return PackageManagerInstallStep( + name: "Update requirements.txt", + confirmation: .none + ) { model in + let requirementsPath = packagePath.appending(path: "requirements.txt") + + let freezeOutput = try await model.executeInDirectory( + in: packagePath.path(percentEncoded: false), + ["\(pipCommand)", "freeze"] + ) + + await model.status("Writing requirements to requirements.txt") + let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" + try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) + } + } + + // MARK: - Verify Installation + + private func verifyInstallation(_ source: PackageSource, in packagePath: URL) -> PackageManagerInstallStep { + let pipCommand = getPipCommand(in: packagePath) + return PackageManagerInstallStep( + name: "Verify Installation", + confirmation: .none + ) { model in + let output = try await model.executeInDirectory( + in: packagePath.path(percentEncoded: false), + ["\(pipCommand)", "list", "--format=freeze"] + ) + + // Normalize package names for comparison + let normalizedPackageHyphen = source.pkgName.replacingOccurrences(of: "_", with: "-").lowercased() + let normalizedPackageUnderscore = source.pkgName.replacingOccurrences(of: "-", with: "_").lowercased() + + // Check if the package name appears in requirements.txt + let installedPackages = output.map { line in + line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) + } + let packageFound = installedPackages.contains { installedPackage in + installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore + } + + guard packageFound else { + throw PackageManagerError.installationFailed("Package \(source.pkgName) not found in pip list") + } + } + } + + private func getPipCommand(in packagePath: URL) -> String { + let venvPip = "venv/bin/pip" + return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) + ? venvPip + : "python3 -m pip" + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift index 9a3b5621de..86ec7ec2fe 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -9,28 +9,18 @@ import Foundation extension RegistryManager { /// Downloads the latest registry - func update() async { - // swiftlint:disable:next large_tuple - let result = await Task.detached(priority: .userInitiated) { () -> ( - registryData: Data?, checksumData: Data?, error: Error? - ) in - do { - async let zipDataTask = Self.download(from: self.registryURL) - async let checksumsTask = Self.download(from: self.checksumURL) - - let (registryData, checksumData) = try await (zipDataTask, checksumsTask) - return (registryData, checksumData, nil) - } catch { - return (nil, nil, error) - } - }.value + func downloadRegistryItems() async { + isDownloadingRegistry = true + defer { isDownloadingRegistry = false } - if let error = result.error { - handleUpdateError(error) - return - } + let registryData, checksumData: Data + do { + async let zipDataTask = download(from: self.registryURL) + async let checksumsTask = download(from: self.checksumURL) - guard let registryData = result.registryData, let checksumData = result.checksumData else { + (registryData, checksumData) = try await (zipDataTask, checksumsTask) + } catch { + handleUpdateError(error) return } @@ -56,17 +46,29 @@ extension RegistryManager { try FileManager.default.removeItem(at: tempZipURL) try checksumData.write(to: checksumDestination) - - NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) + downloadError = nil } catch { - logger.error("Error updating: \(error)") handleUpdateError(RegistryManagerError.writeFailed(error: error)) + return + } + + do { + if let items = loadItemsFromDisk() { + setRegistryItems(items) + } else { + throw RegistryManagerError.failedToSaveRegistryCache + } + } catch { + handleUpdateError(error) } } func handleUpdateError(_ error: Error) { + self.downloadError = error if let regError = error as? RegistryManagerError { switch regError { + case .installationRunning: + return // Shouldn't need to handle case .invalidResponse(let statusCode): logger.error("Invalid response received: \(statusCode)") case let .downloadFailed(url, error): @@ -75,6 +77,8 @@ extension RegistryManager { logger.error("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") case let .writeFailed(error): logger.error("Failed to write files to disk: \(error.localizedDescription)") + case .failedToSaveRegistryCache: + logger.error("Failed to read registry from cache after download and write.") } } else { logger.error("Unexpected registry error: \(error.localizedDescription)") @@ -82,7 +86,8 @@ extension RegistryManager { } /// Attempts downloading from `url`, with error handling and a retry policy - static func download(from url: URL, attempt: Int = 1) async throws -> Data { + @Sendable + func download(from url: URL, attempt: Int = 1) async throws -> Data { do { let (data, response) = try await URLSession.shared.data(from: url) @@ -123,7 +128,6 @@ extension RegistryManager { }() if needsUpdate { - Task { await update() } return nil } @@ -132,7 +136,6 @@ extension RegistryManager { let items = try JSONDecoder().decode([RegistryItem].self, from: registryData) return items.filter { $0.categories.contains("LSP") } } catch { - Task { await update() } return nil } } diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift deleted file mode 100644 index 06bddba5a0..0000000000 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// RegistryManager+Parsing.swift -// CodeEdit -// -// Created by Abe Malla on 3/14/25. -// - -import Foundation - -extension RegistryManager { - /// Parse a registry entry and create the appropriate installation method - internal static func parseRegistryEntry(_ entry: RegistryItem) -> InstallationMethod { - let sourceId = entry.source.id - if sourceId.hasPrefix("pkg:cargo/") { - return PackageSourceParser.parseCargoPackage(entry) - } else if sourceId.hasPrefix("pkg:npm/") { - return PackageSourceParser.parseNpmPackage(entry) - } else if sourceId.hasPrefix("pkg:pypi/") { - return PackageSourceParser.parsePythonPackage(entry) - } else if sourceId.hasPrefix("pkg:gem/") { - return PackageSourceParser.parseRubyGem(entry) - } else if sourceId.hasPrefix("pkg:golang/") { - return PackageSourceParser.parseGolangPackage(entry) - } else if sourceId.hasPrefix("pkg:github/") { - return PackageSourceParser.parseGithubPackage(entry) - } else { - return .unknown - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index a2d0945c5a..c287f85519 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -8,13 +8,13 @@ import OSLog import Foundation import ZIPFoundation +import Combine @MainActor -final class RegistryManager { - static let shared: RegistryManager = .init() +final class RegistryManager: ObservableObject { + static let shared = RegistryManager() let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RegistryManager") - let installPath = Settings.shared.baseURL.appending(path: "Language Servers") /// The URL of where the registry.json file will be downloaded from @@ -26,87 +26,51 @@ final class RegistryManager { string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! + @Published var isDownloadingRegistry: Bool = false + /// Holds an errors found while downloading the registry file. Needs a UI to dismiss, is logged. + @Published var downloadError: Error? + /// Any currently running installation operation. + @Published var runningInstall: PackageManagerInstallOperation? + private var installTask: Task? + + /// Indicates if the manager is currently installing a package. + var isInstalling: Bool { + installTask != nil + } + /// Reference to cached registry data. Will be removed from memory after a certain amount of time. private var cachedRegistry: CachedRegistry? /// Timer to clear expired cache private var cleanupTimer: Timer? /// Public access to registry items with cache management - public var registryItems: [RegistryItem] { - if let cache = cachedRegistry, !cache.isExpired { - return cache.items - } + @Published public private(set) var registryItems: [RegistryItem] = [] + + @AppSettings(\.languageServers.installedLanguageServers) + var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + init() { // Load the registry items from disk again after cache expires if let items = loadItemsFromDisk() { - cachedRegistry = CachedRegistry(items: items) - - // Set up timer to clear the cache after expiration - cleanupTimer?.invalidate() - cleanupTimer = Timer.scheduledTimer( - withTimeInterval: CachedRegistry.expirationInterval, repeats: false - ) { [weak self] _ in - Task { @MainActor in - guard let self = self else { return } - self.cachedRegistry = nil - self.cleanupTimer = nil - } + setRegistryItems(items) + } else { + Task { + await downloadRegistryItems() } - return items } - - return [] } - @AppSettings(\.languageServers.installedLanguageServers) - var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] - deinit { cleanupTimer?.invalidate() } - func installPackage(package entry: RegistryItem) async throws { - return try await Task.detached(priority: .userInitiated) { () in - let method = await Self.parseRegistryEntry(entry) - guard let manager = await self.createPackageManager(for: method) else { - throw PackageManagerError.invalidConfiguration - } - - // Add to activity viewer - let activityTitle = "\(entry.name)\("@" + (method.version ?? "latest"))" - await MainActor.run { - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": entry.name, - "action": "create", - "title": "Installing \(activityTitle)" - ] - ) - } - - do { - try await manager.install(method: method) - } catch { - await MainActor.run { - Self.updateActivityViewer(entry.name, activityTitle, fail: true) - } - // Throw error again so the UI can catch it - throw error - } + // MARK: - Enable/Disable - // Update settings on the main thread - await MainActor.run { - self.installedLanguageServers[entry.name] = .init( - packageName: entry.name, - isEnabled: true, - version: method.version ?? "" - ) - Self.updateActivityViewer(entry.name, activityTitle, fail: false) - } - }.value + func setPackageEnabled(packageName: String, enabled: Bool) { + installedLanguageServers[packageName]?.isEnabled = enabled } + // MARK: - Uninstall + @MainActor func removeLanguageServer(packageName: String) async throws { let packageName = packageName.removingPercentEncoding ?? packageName @@ -138,9 +102,79 @@ final class RegistryManager { } } + // MARK: - Install + + public func installOperation(package: RegistryItem) throws -> PackageManagerInstallOperation { + guard !isInstalling else { + throw RegistryManagerError.installationRunning + } + guard let method = package.installMethod, + let manager = method.packageManager(installPath: installPath) else { + throw PackageManagerError.invalidConfiguration + } + let installSteps = try manager.install(method: method) + return PackageManagerInstallOperation(package: package, steps: installSteps) + } + + /// Starts the actual installation process for a package + public func startInstallation(operation installOperation: PackageManagerInstallOperation) throws { + guard !isInstalling else { + throw RegistryManagerError.installationRunning + } + + guard let method = installOperation.package.installMethod else { + throw PackageManagerError.invalidConfiguration + } + + // Run it! + installPackage(operation: installOperation, method: method) + } + + private func installPackage(operation: PackageManagerInstallOperation, method: InstallationMethod) { + installTask = Task { [weak self] in + defer { + self?.installTask = nil + self?.runningInstall = nil + } + self?.runningInstall = operation + + // Add to activity viewer + let activityTitle = "\(operation.package.name)\("@" + (method.version ?? "latest"))" + TaskNotificationHandler.postTask( + action: .create, + model: TaskNotificationModel(id: operation.package.name, title: "Installing \(activityTitle)") + ) + + guard !Task.isCancelled else { return } + + do { + try await operation.run() + } catch { + self?.updateActivityViewer(operation.package.name, activityTitle, fail: true) + return + } + + self?.installedLanguageServers[operation.package.name] = .init( + packageName: operation.package.name, + isEnabled: true, + version: method.version ?? "" + ) + self?.updateActivityViewer(operation.package.name, activityTitle, fail: false) + } + } + + // MARK: - Cancel Install + + /// Cancel the currently running installation + public func cancelInstallation() { + runningInstall?.cancel() + installTask?.cancel() + installTask = nil + } + /// Updates the activity viewer with the status of the language server installation @MainActor - private static func updateActivityViewer( + private func updateActivityViewer( _ id: String, _ activityName: String, fail failed: Bool @@ -155,15 +189,9 @@ final class RegistryManager { action: {}, ) } else { - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": id, - "action": "update", - "title": "Successfully installed \(activityName)", - "isLoading": false - ] + TaskNotificationHandler.postTask( + action: .update, + model: TaskNotificationModel(id: id, title: "Successfully installed \(activityName)", isLoading: false) ) NotificationCenter.default.post( name: .taskNotification, @@ -177,25 +205,25 @@ final class RegistryManager { } } - /// Create the appropriate package manager for the given installation method - func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { - switch method.packageManagerType { - case .npm: - return NPMPackageManager(installationDirectory: installPath) - case .cargo: - return CargoPackageManager(installationDirectory: installPath) - case .pip: - return PipPackageManager(installationDirectory: installPath) - case .golang: - return GolangPackageManager(installationDirectory: installPath) - case .github, .sourceBuild: - return GithubPackageManager(installationDirectory: installPath) - case .nuget, .opam, .gem, .composer: - // TODO: IMPLEMENT OTHER PACKAGE MANAGERS - return nil - case .none: - return nil + // MARK: - Cache + + func setRegistryItems(_ items: [RegistryItem]) { + cachedRegistry = CachedRegistry(items: items) + + // Set up timer to clear the cache after expiration + cleanupTimer?.invalidate() + cleanupTimer = Timer.scheduledTimer( + withTimeInterval: CachedRegistry.expirationInterval, repeats: false + ) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + self.cachedRegistry = nil + self.cleanupTimer = nil + await self.downloadRegistryItems() + } } + + registryItems = items } } @@ -217,7 +245,3 @@ private final class CachedRegistry { Date().timeIntervalSince(timestamp) > Self.expirationInterval } } - -extension Notification.Name { - static let RegistryUpdatedNotification = Notification.Name("registryUpdatedNotification") -} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift deleted file mode 100644 index ac0da00b38..0000000000 --- a/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// RegistryManagerError.swift -// CodeEdit -// -// Created by Abe Malla on 5/12/25. -// - -import Foundation - -enum RegistryManagerError: Error { - case invalidResponse(statusCode: Int) - case downloadFailed(url: URL, error: Error) - case maxRetriesExceeded(url: URL, lastError: Error) - case writeFailed(error: Error) -} diff --git a/CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift b/CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift new file mode 100644 index 0000000000..5006fa58f2 --- /dev/null +++ b/CodeEdit/Features/Search/FuzzySearch/FuzzySearchUIModel.swift @@ -0,0 +1,51 @@ +// +// FuzzySearchUIModel.swift +// CodeEdit +// +// Created by Khan Winter on 8/14/25. +// + +import Foundation +import Combine + +@MainActor +final class FuzzySearchUIModel: ObservableObject { + @Published var items: [Element]? + + private var allItems: [Element] = [] + private var textStream: AsyncStream + private var textStreamContinuation: AsyncStream.Continuation + private var searchTask: Task? + + init(debounceTime: Duration = .milliseconds(50)) { + (textStream, textStreamContinuation) = AsyncStream.makeStream() + + searchTask = Task { [weak self] in + guard let self else { return } + + for await text in textStream.debounce(for: debounceTime) { + await performSearch(query: text) + } + } + } + + func searchTextUpdated(searchText: String, allItems: [Element]) { + self.allItems = allItems + textStreamContinuation.yield(searchText) + } + + private func performSearch(query: String) async { + guard !query.isEmpty else { + items = nil + return + } + + let results = await allItems.fuzzySearch(query: query) + items = results.map { $0.item } + } + + deinit { + textStreamContinuation.finish() + searchTask?.cancel() + } +} diff --git a/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift b/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift index 3ed438ac5f..9cb60c02c4 100644 --- a/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift +++ b/CodeEdit/Features/Search/Views/QuickSearchResultLabel.swift @@ -13,6 +13,7 @@ import SwiftUI struct QuickSearchResultLabel: NSViewRepresentable { let labelName: String let charactersToHighlight: [NSRange] + let maximumNumberOfLines: Int = 1 var nsLabelName: NSAttributedString? public func makeNSView(context: Context) -> some NSTextField { @@ -26,7 +27,7 @@ struct QuickSearchResultLabel: NSViewRepresentable { label.allowsDefaultTighteningForTruncation = false label.cell?.truncatesLastVisibleLine = true label.cell?.wraps = true - label.maximumNumberOfLines = 1 + label.maximumNumberOfLines = maximumNumberOfLines label.attributedStringValue = nsLabelName ?? highlight() return label } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift new file mode 100644 index 0000000000..7562668c65 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerInstallView.swift @@ -0,0 +1,229 @@ +// +// LanguageServerInstallView.swift +// CodeEdit +// +// Created by Khan Winter on 8/14/25. +// + +import SwiftUI + +/// A view for initiating a package install and monitoring progress. +struct LanguageServerInstallView: View { + @Environment(\.dismiss) + var dismiss + @EnvironmentObject private var registryManager: RegistryManager + + @ObservedObject var operation: PackageManagerInstallOperation + + var body: some View { + VStack(spacing: 0) { + formContent + Divider() + footer + } + .constrainHeightToWindow() + .alert( + "Confirm Step", + isPresented: Binding(get: { operation.waitingForConfirmation != nil }, set: { _ in }), + presenting: operation.waitingForConfirmation + ) { _ in + Button("Cancel") { + registryManager.cancelInstallation() + } + Button("Continue") { + operation.confirmCurrentStep() + } + } message: { confirmationMessage in + Text(confirmationMessage) + } + } + + @ViewBuilder private var formContent: some View { + Form { + packageInfoSection + errorSection + if operation.runningState == .running || operation.runningState == .complete { + progressSection + outputSection + } else { + notInstalledSection + } + } + .formStyle(.grouped) + } + + @ViewBuilder private var footer: some View { + HStack { + Spacer() + switch operation.runningState { + case .none: + Button { + dismiss() + } label: { + Text("Cancel") + } + .buttonStyle(.bordered) + Button { + do { + try registryManager.startInstallation(operation: operation) + } catch { + // Display the error + NSAlert(error: error).runModal() + } + } label: { + Text("Install") + } + .buttonStyle(.borderedProminent) + case .running: + Button { + registryManager.cancelInstallation() + dismiss() + } label: { + Text("Cancel") + .frame(minWidth: 56) + } + .buttonStyle(.bordered) + case .complete: + Button { + dismiss() + } label: { + Text("Continue") + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + } + } + .padding() + } + + @ViewBuilder private var packageInfoSection: some View { + Section { + LabeledContent("Installing Package", value: operation.package.sanitizedName) + LabeledContent("Homepage") { + sourceButton.cursor(.pointingHand) + } + VStack(alignment: .leading, spacing: 6) { + Text("Description") + Text(operation.package.sanitizedDescription) + .multilineTextAlignment(.leading) + .foregroundColor(.secondary) + .labelsHidden() + .textSelection(.enabled) + } + } + } + + @ViewBuilder private var errorSection: some View { + if let error = operation.error { + Section { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) + Text("Error Occurred") + } + .font(.title3) + ErrorDescriptionLabel(error: error) + } + } + } + + @ViewBuilder private var sourceButton: some View { + if #available(macOS 14.0, *) { + Button(operation.package.homepagePretty) { + guard let homepage = operation.package.homepageURL else { return } + NSWorkspace.shared.open(homepage) + } + .buttonStyle(.plain) + .foregroundColor(Color(NSColor.linkColor)) + .focusEffectDisabled() + } else { + Button(operation.package.homepagePretty) { + guard let homepage = operation.package.homepageURL else { return } + NSWorkspace.shared.open(homepage) + } + .buttonStyle(.plain) + .foregroundColor(Color(NSColor.linkColor)) + } + } + + @ViewBuilder private var progressSection: some View { + Section { + LabeledContent("Step") { + if registryManager.installedLanguageServers[operation.package.name] != nil { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Successfully Installed") + .foregroundStyle(.primary) + } + } else if operation.error != nil { + Text("Error Occurred") + } else { + Text(operation.currentStep?.name ?? "") + } + } + ProgressView(operation.progress) + .progressViewStyle(.linear) + } + } + + @ViewBuilder private var outputSection: some View { + Section { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + ForEach(operation.accumulatedOutput) { line in + VStack { + if line.isStepDivider && line != operation.accumulatedOutput.first { + Divider() + } + HStack(alignment: .firstTextBaseline, spacing: 6) { + ZStack { + if let idx = line.outputIdx { + Text(String(idx)) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + } + Text(String(10)) // Placeholder for spacing + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .opacity(0.0) + } + Text(line.contents) + .font(.caption.monospaced()) + .foregroundStyle(line.isStepDivider ? .primary : .secondary) + .textSelection(.enabled) + Spacer(minLength: 0) + } + } + .tag(line.id) + .id(line.id) + } + } + } + .onReceive(operation.$accumulatedOutput) { output in + DispatchQueue.main.async { + withAnimation(.linear(duration: 0.1)) { + proxy.scrollTo(output.last?.id) + } + } + } + } + } + .frame(height: 200) + } + + @ViewBuilder private var notInstalledSection: some View { + Section { + if let method = operation.package.installMethod { + LabeledContent("Install Method", value: method.installerDescription) + .textSelection(.enabled) + if let packageDescription = method.packageDescription { + LabeledContent("Package", value: packageDescription) + .textSelection(.enabled) + } + } else { + LabeledContent("Installer", value: "Unknown") + } + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index b1cdb7c6f3..02f423f8a0 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -10,64 +10,85 @@ import SwiftUI private let iconSize: CGFloat = 26 struct LanguageServerRowView: View, Equatable { - let packageName: String - let subtitle: String + let package: RegistryItem let onCancel: (() -> Void) let onInstall: (() async -> Void) - private let cleanedTitle: String - private let cleanedSubtitle: String + private var isInstalled: Bool { + registryManager.installedLanguageServers[package.name] != nil + } + private var isEnabled: Bool { + registryManager.installedLanguageServers[package.name]?.isEnabled ?? false + } @State private var isHovering: Bool = false - @State private var installationStatus: PackageInstallationStatus = .notQueued - @State private var isInstalled: Bool = false - @State private var isEnabled = false @State private var showingRemovalConfirmation = false @State private var isRemoving = false @State private var removalError: Error? @State private var showingRemovalError = false + @State private var showMore: Bool = false + + @EnvironmentObject var registryManager: RegistryManager + init( - packageName: String, - subtitle: String, - isInstalled: Bool = false, - isEnabled: Bool = false, + package: RegistryItem, onCancel: @escaping (() -> Void), onInstall: @escaping () async -> Void ) { - self.packageName = packageName - self.subtitle = subtitle - self.isInstalled = isInstalled - self.isEnabled = isEnabled + self.package = package self.onCancel = onCancel self.onInstall = onInstall - - self.cleanedTitle = packageName - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: "_", with: " ") - .split(separator: " ") - .map { word -> String in - let str = String(word).lowercased() - // Check for special cases - if str == "ls" || str == "lsp" || str == "ci" || str == "cli" { - return str.uppercased() - } - return str.capitalized - } - .joined(separator: " ") - self.cleanedSubtitle = subtitle.replacingOccurrences(of: "\n", with: " ") } var body: some View { HStack { Label { VStack(alignment: .leading) { - Text(cleanedTitle) - Text(cleanedSubtitle) - .font(.footnote) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) + Text(package.sanitizedName) + + ZStack(alignment: .leadingLastTextBaseline) { + VStack(alignment: .leading) { + Text(package.sanitizedDescription) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(showMore ? nil : 1) + .truncationMode(.tail) + if showMore { + Button(package.homepagePretty) { + guard let url = package.homepageURL else { return } + NSWorkspace.shared.open(url) + } + .buttonStyle(.plain) + .foregroundColor(Color(NSColor.linkColor)) + .font(.footnote) + .cursor(.pointingHand) + if let installerName = package.installMethod?.packageManagerType?.rawValue { + Text("Install using \(installerName)") + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + if isHovering { + HStack { + Spacer() + Button { + showMore.toggle() + } label: { + Text(showMore ? "Show Less" : "Show More") + .font(.footnote) + } + .buttonStyle(.plain) + .background( + Rectangle() + .inset(by: -2) + .fill(.clear) + .background(Color(NSColor.windowBackgroundColor)) + ) + } + } + } } } icon: { letterIcon() @@ -81,22 +102,7 @@ struct LanguageServerRowView: View, Equatable { .onHover { hovering in isHovering = hovering } - .onAppear { - // Check if this package is already in the installation queue - installationStatus = InstallationQueueManager.shared.getInstallationStatus(packageName: packageName) - } - .onReceive(NotificationCenter.default.publisher(for: .installationStatusChanged)) { notification in - if let notificationPackageName = notification.userInfo?["packageName"] as? String, - notificationPackageName == packageName, - let status = notification.userInfo?["status"] as? PackageInstallationStatus { - installationStatus = status - if case .installed = status { - isInstalled = true - isEnabled = true - } - } - } - .alert("Remove \(cleanedTitle)?", isPresented: $showingRemovalConfirmation) { + .alert("Remove \(package.sanitizedName)?", isPresented: $showingRemovalConfirmation) { Button("Cancel", role: .cancel) { } Button("Remove", role: .destructive) { removeLanguageServer() @@ -115,17 +121,10 @@ struct LanguageServerRowView: View, Equatable { private func installationButton() -> some View { if isInstalled { installedRow() - } else { - switch installationStatus { - case .installing, .queued: - isInstallingRow() - case .failed: - failedRow() - default: - if isHovering { - isHoveringRow() - } - } + } else if registryManager.runningInstall?.package.name == package.name { + isInstallingRow() + } else if isHovering { + isHoveringRow() } } @@ -133,8 +132,8 @@ struct LanguageServerRowView: View, Equatable { private func installedRow() -> some View { HStack { if isRemoving { - ProgressView() - .controlSize(.small) + CECircularProgressView() + .frame(width: 20, height: 20) } else if isHovering { Button { showingRemovalConfirmation = true @@ -142,30 +141,27 @@ struct LanguageServerRowView: View, Equatable { Text("Remove") } } - Toggle("", isOn: $isEnabled) - .onChange(of: isEnabled) { newValue in - RegistryManager.shared.installedLanguageServers[packageName]?.isEnabled = newValue - } - .toggleStyle(.switch) - .controlSize(.small) - .labelsHidden() + Toggle( + "", + isOn: Binding( + get: { isEnabled }, + set: { registryManager.setPackageEnabled(packageName: package.name, enabled: $0) } + ) + ) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() } } @ViewBuilder private func isInstallingRow() -> some View { HStack { - if case .queued = installationStatus { - Text("Queued") - .font(.caption) - .foregroundColor(.secondary) - } - ZStack { CECircularProgressView() .frame(width: 20, height: 20) + Button { - InstallationQueueManager.shared.cancelInstallation(packageName: packageName) onCancel() } label: { Image(systemName: "stop.fill") @@ -181,8 +177,6 @@ struct LanguageServerRowView: View, Equatable { @ViewBuilder private func failedRow() -> some View { Button { - // Reset status and retry installation - installationStatus = .notQueued Task { await onInstall() } @@ -201,6 +195,7 @@ struct LanguageServerRowView: View, Equatable { } label: { Text("Install") } + .disabled(registryManager.isInstalling) } @ViewBuilder @@ -208,7 +203,7 @@ struct LanguageServerRowView: View, Equatable { RoundedRectangle(cornerRadius: iconSize / 4, style: .continuous) .fill(background) .overlay { - Text(String(cleanedTitle.first ?? Character(""))) + Text(String(package.sanitizedName.first ?? Character(""))) .font(.system(size: iconSize * 0.65)) .foregroundColor(.primary) } @@ -225,11 +220,9 @@ struct LanguageServerRowView: View, Equatable { isRemoving = true Task { do { - try await RegistryManager.shared.removeLanguageServer(packageName: packageName) + try await registryManager.removeLanguageServer(packageName: package.name) await MainActor.run { isRemoving = false - isInstalled = false - isEnabled = false } } catch { await MainActor.run { @@ -245,11 +238,11 @@ struct LanguageServerRowView: View, Equatable { let colors: [Color] = [ .blue, .green, .orange, .red, .purple, .pink, .teal, .yellow, .indigo, .cyan ] - let hashValue = abs(cleanedTitle.hash) % colors.count + let hashValue = abs(package.sanitizedName.hash) % colors.count return AnyShapeStyle(colors[hashValue].gradient) } static func == (lhs: LanguageServerRowView, rhs: LanguageServerRowView) -> Bool { - lhs.packageName == rhs.packageName && lhs.subtitle == rhs.subtitle + lhs.package.name == rhs.package.name } } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 6849295f4c..aa1f1689cd 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -7,49 +7,49 @@ import SwiftUI +/// Displays a searchable list of packages from the ``RegistryManager``. struct LanguageServersView: View { - @State private var didError = false - @State private var installationFailure: InstallationFailure? - @State private var registryItems: [RegistryItem] = [] - @State private var isLoading = true + @StateObject var registryManager: RegistryManager = .shared + @StateObject private var searchModel = FuzzySearchUIModel() + @State private var searchText: String = "" + @State private var selectedInstall: PackageManagerInstallOperation? @State private var showingInfoPanel = false var body: some View { - SettingsForm { - if isLoading { - HStack { - Spacer() - ProgressView() - .controlSize(.small) - Spacer() + Group { + SettingsForm { + if registryManager.isDownloadingRegistry { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } } - } else { + Section { - List(registryItems, id: \.name) { item in + List(searchModel.items ?? registryManager.registryItems, id: \.name) { item in LanguageServerRowView( - packageName: item.name, - subtitle: item.description, - isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, - isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, + package: item, onCancel: { - InstallationQueueManager.shared.cancelInstallation(packageName: item.name) + registryManager.cancelInstallation() }, - onInstall: { - let item = item // Capture for closure - InstallationQueueManager.shared.queueInstallation(package: item) { result in - switch result { - case .success: - break - case .failure(let error): - didError = true - installationFailure = InstallationFailure(error: error.localizedDescription) - } + onInstall: { [item] in + do { + selectedInstall = try registryManager.installOperation(package: item) + } catch { + // Display the error + NSAlert(error: error).runModal() } } ) .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } + .searchable(text: $searchText) + .onChange(of: searchText) { newValue in + searchModel.searchTextUpdated(searchText: newValue, allItems: registryManager.registryItems) + } } header: { Label( "Warning: Language server installation is experimental. Use at your own risk.", @@ -57,55 +57,11 @@ struct LanguageServersView: View { ) } } - } - .onAppear { - loadRegistryItems() - } - .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in - loadRegistryItems() - } - .onDisappear { - InstallationQueueManager.shared.cleanUpInstallationStatus() - } - .alert( - "Installation Failed", - isPresented: $didError, - presenting: installationFailure - ) { _ in - Button("Dismiss") { } - } message: { details in - Text(details.error) - } - .toolbar { - Button { - showingInfoPanel.toggle() - } label: { - Image(systemName: "questionmark.circle") - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .popover(isPresented: $showingInfoPanel, arrowEdge: .top) { - VStack(alignment: .leading) { - HStack { - Text("Language Server Installation").font(.title2) - Spacer() - } - .frame(width: 300) - Text(getInfoString()) - .lineLimit(nil) - .frame(width: 300) - } - .padding() + .sheet(item: $selectedInstall) { operation in + LanguageServerInstallView(operation: operation) } } - } - - private func loadRegistryItems() { - isLoading = true - registryItems = RegistryManager.shared.registryItems - if !registryItems.isEmpty { - isLoading = false - } + .environmentObject(registryManager) } private func getInfoString() -> AttributedString { @@ -125,8 +81,3 @@ struct LanguageServersView: View { return attrString } } - -private struct InstallationFailure: Identifiable { - let error: String - let id = UUID() -} diff --git a/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift b/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift index faffba32b2..1e721d6a34 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift +++ b/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift @@ -11,11 +11,9 @@ import SwiftTerm class CEActiveTaskTerminalView: CELocalShellTerminalView { var activeTask: CEActiveTask - private var cachedCaretColor: NSColor? var isUserCommandRunning: Bool { activeTask.status == .running || activeTask.status == .stopped } - private var enableOutput: Bool = false init(activeTask: CEActiveTask) { self.activeTask = activeTask diff --git a/CodeEdit/Utils/Extensions/FileManager/FileManager+MakeExecutable.swift b/CodeEdit/Utils/Extensions/FileManager/FileManager+MakeExecutable.swift new file mode 100644 index 0000000000..73b3ea0aeb --- /dev/null +++ b/CodeEdit/Utils/Extensions/FileManager/FileManager+MakeExecutable.swift @@ -0,0 +1,26 @@ +// +// FileManager+MakeExecutable.swift +// CodeEdit +// +// Created by Khan Winter on 8/14/25. +// + +import Foundation + +extension FileManager { + /// Make a given URL executable via POSIX permissions. + /// Slightly different from `chmod +x`, does not give execute permissions for users besides + /// the current user. + /// - Parameter executableURL: The URL of the file to make executable. + func makeExecutable(_ executableURL: URL) throws { + let fileAttributes = try FileManager.default.attributesOfItem( + atPath: executableURL.path(percentEncoded: false) + ) + guard var permissions = fileAttributes[.posixPermissions] as? UInt16 else { return } + permissions |= 0b001_000_000 // Execute perms for user, not group, not others + try FileManager.default.setAttributes( + [.posixPermissions: permissions], + ofItemAtPath: executableURL.path(percentEncoded: false) + ) + } +} diff --git a/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift b/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift new file mode 100644 index 0000000000..c9b76cb5f9 --- /dev/null +++ b/CodeEdit/Utils/Extensions/ZipFoundation/ZipFoundation+ErrorDescrioption.swift @@ -0,0 +1,60 @@ +// +// ZipFoundation+ErrorDescrioption.swift +// CodeEdit +// +// Created by Khan Winter on 8/14/25. +// + +import Foundation +import ZIPFoundation + +extension Archive.ArchiveError: @retroactive LocalizedError { + public var errorDescription: String? { + switch self { + case .unreadableArchive: + "Unreadable archive." + case .unwritableArchive: + "Unwritable archive." + case .invalidEntryPath: + "Invalid entry path." + case .invalidCompressionMethod: + "Invalid compression method." + case .invalidCRC32: + "Invalid checksum." + case .cancelledOperation: + "Operation cancelled." + case .invalidBufferSize: + "Invalid buffer size." + case .invalidEntrySize: + "Invalid entry size." + case .invalidLocalHeaderDataOffset, + .invalidLocalHeaderSize, + .invalidCentralDirectoryOffset, + .invalidCentralDirectorySize, + .invalidCentralDirectoryEntryCount, + .missingEndOfCentralDirectoryRecord: + "Invalid file detected." + case .uncontainedSymlink: + "Uncontained symlink detected." + } + } + + public var failureReason: String? { + return switch self { + case .invalidLocalHeaderDataOffset: + "Invalid local header data offset." + case .invalidLocalHeaderSize: + "Invalid local header size." + case .invalidCentralDirectoryOffset: + "Invalid central directory offset." + case .invalidCentralDirectorySize: + "Invalid central directory size." + case .invalidCentralDirectoryEntryCount: + "Invalid central directory entry count." + case .missingEndOfCentralDirectoryRecord: + "Missing end of central directory record." + default: + nil + } + } +} diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift index baf99e7cfe..37dcfe513b 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift @@ -21,7 +21,9 @@ class ShellClient { /// - Parameter args: commands to run /// - Returns: command output func generateProcessAndPipe(_ args: [String]) -> (Process, Pipe) { - var arguments = ["-l", "-c"] + // Run in an 'interactive' login shell. Because we're passing -c here it won't actually be + // interactive but it will source the user's zshrc file as well as the zshprofile. + var arguments = ["-lic"] arguments.append(contentsOf: args) let task = Process() let pipe = Pipe() diff --git a/CodeEditTests/Features/LSP/Registry.swift b/CodeEditTests/Features/LSP/Registry.swift index a450e67b12..fcbc4df851 100644 --- a/CodeEditTests/Features/LSP/Registry.swift +++ b/CodeEditTests/Features/LSP/Registry.swift @@ -5,53 +5,60 @@ // Created by Abe Malla on 2/2/25. // -import XCTest +import Testing +import Foundation @testable import CodeEdit @MainActor -final class RegistryTests: XCTestCase { - var registry: RegistryManager = RegistryManager.shared +@Suite() +struct RegistryTests { + var registry: RegistryManager = RegistryManager() // MARK: - Download Tests - func testRegistryDownload() async throws { - await registry.update() + @Test + func registryDownload() async throws { + await registry.downloadRegistryItems() - let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") - let checksumPath = Settings.shared.baseURL.appending(path: "extensions/checksums.txt") + #expect(registry.downloadError == nil) - XCTAssertTrue(FileManager.default.fileExists(atPath: registryJsonPath.path), "Registry JSON file should exist.") - XCTAssertTrue(FileManager.default.fileExists(atPath: checksumPath.path), "Checksum file should exist.") + let registryJsonPath = registry.installPath.appending(path: "registry.json") + let checksumPath = registry.installPath.appending(path: "checksums.txt") + + #expect(FileManager.default.fileExists(atPath: registryJsonPath.path), "Registry JSON file should exist.") + #expect(FileManager.default.fileExists(atPath: checksumPath.path), "Checksum file should exist.") } // MARK: - Decoding Tests - func testRegistryDecoding() async throws { - await registry.update() + @Test + func registryDecoding() async throws { + await registry.downloadRegistryItems() - let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let registryJsonPath = registry.installPath.appending(path: "registry.json") let jsonData = try Data(contentsOf: registryJsonPath) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let entries = try decoder.decode([RegistryItem].self, from: jsonData) - XCTAssertFalse(entries.isEmpty, "Registry should not be empty after decoding.") + #expect(entries.isEmpty == false, "Registry should not be empty after decoding.") if let actionlint = entries.first(where: { $0.name == "actionlint" }) { - XCTAssertEqual(actionlint.description, "Static checker for GitHub Actions workflow files.") - XCTAssertEqual(actionlint.licenses, ["MIT"]) - XCTAssertEqual(actionlint.languages, ["YAML"]) - XCTAssertEqual(actionlint.categories, ["Linter"]) + #expect(actionlint.description == "Static checker for GitHub Actions workflow files.") + #expect(actionlint.licenses == ["MIT"]) + #expect(actionlint.languages == ["YAML"]) + #expect(actionlint.categories == ["Linter"]) } else { - XCTFail("Could not find actionlint in registry") + Issue.record("Could not find actionlint in registry") } } - func testHandlesVersionOverrides() async throws { - await registry.update() + @Test + func handlesVersionOverrides() async throws { + await registry.downloadRegistryItems() - let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let registryJsonPath = registry.installPath.appending(path: "registry.json") let jsonData = try Data(contentsOf: registryJsonPath) let decoder = JSONDecoder() @@ -59,10 +66,10 @@ final class RegistryTests: XCTestCase { let entries = try decoder.decode([RegistryItem].self, from: jsonData) if let adaServer = entries.first(where: { $0.name == "ada-language-server" }) { - XCTAssertNotNil(adaServer.source.versionOverrides, "Version overrides should be present.") - XCTAssertFalse(adaServer.source.versionOverrides!.isEmpty, "Version overrides should not be empty.") + #expect(adaServer.source.versionOverrides != nil, "Version overrides should be present.") + #expect(adaServer.source.versionOverrides!.isEmpty == false, "Version overrides should not be empty.") } else { - XCTFail("Could not find ada-language-server to test version overrides") + Issue.record("Could not find ada-language-server to test version overrides") } } }