From 5ea9de5bf1225f13b02df4cb3bec676663ac4ef9 Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 24 Feb 2025 22:38:58 -0800 Subject: [PATCH 01/38] Language server installation menu --- CodeEdit.xcodeproj/project.pbxproj | 34 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Models/CEWorkspaceSettings.swift | 4 +- .../LSP/Registry/PackageManagerFactory.swift | 182 ++++++++ .../PackageManagers/CargoPackageManager.swift | 172 +++++++ .../GolangPackageManager.swift | 226 +++++++++ .../PackageManagers/NPMPackageManager.swift | 226 +++++++++ .../PackageManagerProtocol.swift | 185 ++++++++ .../PackageManagers/PackageSourceParser.swift | 433 ++++++++++++++++++ .../PackageManagers/PipPackageManager.swift | 275 +++++++++++ .../LSP/Registry/RegistryManager.swift | 176 +++++++ .../LSP/Registry/RegistryPackage.swift | 149 ++++++ .../Features/LSP/Service/LSPService.swift | 2 +- .../Settings/Models/SettingsData.swift | 8 + .../Settings/Models/SettingsPage.swift | 1 + .../ExtensionsSettingsRowView.swift | 137 ++++++ .../Extensions/ExtensionsSettingsView.swift | 52 +++ .../Extensions/Models/ExtensionSettings.swift | 30 ++ CodeEdit/Features/Settings/SettingsView.swift | 9 + .../ShellClient/Models/ShellClient.swift | 38 +- CodeEditTests/Features/LSP/Registry.swift | 68 +++ 21 files changed, 2412 insertions(+), 6 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/RegistryManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/RegistryPackage.swift create mode 100644 CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift create mode 100644 CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift create mode 100644 CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift create mode 100644 CodeEditTests/Features/LSP/Registry.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index c1dc25d13..7055423d4 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -64,6 +64,8 @@ 3000516A2BBD3A8200A98562 /* ServiceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300051692BBD3A8200A98562 /* ServiceType.swift */; }; 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */; }; 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */; }; + 304672DF2D5235210037C8F1 /* Registry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 304672DE2D52351F0037C8F1 /* Registry.swift */; }; + 30818CB52D4E563900967860 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 30818CB42D4E563900967860 /* ZIPFoundation */; }; 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */; }; 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */; }; 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */; }; @@ -770,6 +772,7 @@ 300051692BBD3A8200A98562 /* ServiceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceType.swift; sourceTree = ""; }; 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceWrapper.swift; sourceTree = ""; }; 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorAreaViewModel.swift; sourceTree = ""; }; + 304672DE2D52351F0037C8F1 /* Registry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Registry.swift; sourceTree = ""; }; 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueTable.swift; sourceTree = ""; }; @@ -1339,6 +1342,11 @@ EC0870F62A455F6400EB8692 /* ProjectNavigatorViewController+NSMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+NSMenuDelegate.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 304672E02D5244E50037C8F1 /* Extensions */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Extensions; sourceTree = ""; }; + 30818CAE2D4E39B600967860 /* Registry */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Registry; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 2BE487E928245162003F3F64 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -1352,6 +1360,7 @@ buildActionMask = 2147483647; files = ( 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, + 30818CB52D4E563900967860 /* ZIPFoundation in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, @@ -1617,6 +1626,7 @@ children = ( 6C3B4CD22D0E2C5400C6759E /* Editor */, 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, + 30818CAE2D4E39B600967860 /* Registry */, 6CD26C742C8EA79100ADBA38 /* Service */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, ); @@ -3179,6 +3189,7 @@ 6CD26C882C8F91B600ADBA38 /* LSP */ = { isa = PBXGroup; children = ( + 304672DE2D52351F0037C8F1 /* Registry.swift */, 6C7D6D452C9092EC00B69EE0 /* BufferingServerConnection.swift */, 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */, 6C3B4CD32D0E2CB000C6759E /* SemanticTokenMapTests.swift */, @@ -3256,6 +3267,7 @@ children = ( B6E41C6E29DD15540088F9F4 /* AccountsSettings */, 30AB4EB72BF7170B00ED4431 /* DeveloperSettings */, + 304672E02D5244E50037C8F1 /* Extensions */, B61DA9E129D929F900BF4A43 /* GeneralSettings */, B6CF632629E5417C0085880A /* Keybindings */, B6F0516E29D9E35300D72287 /* LocationsSettings */, @@ -3805,6 +3817,10 @@ 6C7B1C762A1D57CE005CBBFC /* PBXTargetDependency */, 2BE487F328245162003F3F64 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 304672E02D5244E50037C8F1 /* Extensions */, + 30818CAE2D4E39B600967860 /* Registry */, + ); name = CodeEdit; packageProductDependencies = ( 2816F593280CF50500DD548B /* CodeEditSymbols */, @@ -3826,6 +3842,7 @@ 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */, + 30818CB42D4E563900967860 /* ZIPFoundation */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3924,6 +3941,7 @@ 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4621,6 +4639,7 @@ 6130535F2B23A31300D767E3 /* MemorySearchTests.swift in Sources */, 587B61012934170A00D5CD8F /* UnitTests_Extensions.swift in Sources */, 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */, + 304672DF2D5235210037C8F1 /* Registry.swift in Sources */, 283BDCC52972F236002AFF81 /* AcknowledgementsTests.swift in Sources */, 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */, 4EE96ECB2960565E00FFBEA8 /* DocumentsUnitTests.swift in Sources */, @@ -5754,6 +5773,14 @@ minimumVersion = 0.13.2; }; }; + 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.19; + }; + }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ChimeHQ/LanguageServerProtocol"; @@ -5866,6 +5893,11 @@ package = 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */; productName = CodeEditSymbols; }; + 30818CB42D4E563900967860 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */ = { isa = XCSwiftPackageProductDependency; package = 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b65c217af..b9f7054fd 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ac57a6899925c3e4ac6d43aed791c845c6fc24a4441b6a10297a207d951b7836", + "originHash" : "ea2e6949e960b25a9aba20281f3494a3f9e0d44a4fa5330f71e518c196db2141", "pins" : [ { "identity" : "anycodable", @@ -278,6 +278,15 @@ "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", "version" : "0.23.2" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } } ], "version" : 3 diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift index a48610d8a..8ff0b0249 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift @@ -18,11 +18,11 @@ final class CEWorkspaceSettings: ObservableObject { private(set) var folderURL: URL private var settingsURL: URL { - folderURL.appendingPathComponent("settings").appendingPathExtension("json") + folderURL.appending(path: "settings").appending(path: "json") } init(workspaceURL: URL) { - folderURL = workspaceURL.appendingPathComponent(".codeedit", isDirectory: true) + folderURL = workspaceURL.appending(path: ".codeedit", directoryHint: .isDirectory) loadSettings() storeTask = $settings diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift new file mode 100644 index 000000000..443452142 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift @@ -0,0 +1,182 @@ +// +// PackageManagerFactory.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +/// Factory for creating the appropriate package manager based on installation method +final class PackageManagerFactory { + let installationDirectory: URL + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + } + + /// Create the appropriate package manager for the given installation method + func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { + switch method.packageManagerType { + case .npm: + return NPMPackageManager(installationDirectory: installationDirectory) + case .cargo: + return CargoPackageManager(installationDirectory: installationDirectory) + case .pip: + return PipPackageManager(installationDirectory: installationDirectory) + case .golang: + return GolangPackageManager(installationDirectory: installationDirectory) + case .nuget, .opam, .customBuild, .gem: + // TODO: IMPLEMENT OTHER PACKAGE MANAGERS + return nil + case .github: + return createPackageManagerFromGithub(for: method) + case .none: + return nil + } + } + + /// Parse a registry entry and create the appropriate installation method + static func parseRegistryEntry(_ entry: [String: Any]) -> InstallationMethod? { + guard let source = entry["source"] as? [String: Any], + let sourceId = source["id"] as? String else { + return nil + } + + let buildInstructions = source["build"] as? [[String: Any]] + + // Detect the build tool from the registry entry + var buildTool: String? + if let bin = entry["bin"] as? [String: String] { + let binValues = Array(bin.values) + if !binValues.isEmpty { + let value = binValues[0] + if value.hasPrefix("cargo:") { + buildTool = "cargo" + } else if value.hasPrefix("npm:") { + buildTool = "npm" + } else if value.hasPrefix("pypi:") { + buildTool = "pip" + } else if value.hasPrefix("gem:") { + buildTool = "gem" + } else if value.hasPrefix("golang:") { + buildTool = "golang" + } + } + } + + var method = PackageSourceParser.parse(sourceId, buildInstructions: buildInstructions) + + if let buildTool = buildTool { + switch method { + case .standardPackage(var source): + var options = source.options + options["buildTool"] = buildTool + source = PackageSource( + sourceId: source.sourceId, + type: source.type, + name: source.name, + version: source.version, + subpath: source.subpath, + repositoryUrl: source.repositoryUrl, + gitReference: source.gitReference, + options: options + ) + method = .standardPackage(source: source) + case .sourceBuild(var source, let instructions): + var options = source.options + options["buildTool"] = buildTool + source = PackageSource( + sourceId: source.sourceId, + type: source.type, + name: source.name, + version: source.version, + subpath: source.subpath, + repositoryUrl: source.repositoryUrl, + gitReference: source.gitReference, + options: options + ) + method = .sourceBuild(source: source, buildInstructions: instructions) + case .binaryDownload(var source, let url): + var options = source.options + options["buildTool"] = buildTool + source = PackageSource( + sourceId: source.sourceId, + type: source.type, + name: source.name, + version: source.version, + subpath: source.subpath, + repositoryUrl: source.repositoryUrl, + gitReference: source.gitReference, + options: options + ) + method = .binaryDownload(source: source, url: url) + case .unknown: + break + } + } + return method + } + + /// Install a package from a registry entry + func installFromRegistryEntry(_ entry: [String: Any]) async throws { + guard let method = PackageManagerFactory.parseRegistryEntry(entry), + let manager = createPackageManager(for: method) else { + throw PackageManagerError.invalidConfiguration + } + try await manager.install(method: method) + } + + /// Install a package from a source ID string + func installFromSourceID(_ sourceID: String) async throws { + let method = PackageSourceParser.parse(sourceID) + guard let manager = createPackageManager(for: method) else { + throw PackageManagerError.packageManagerNotInstalled + } + try await manager.install(method: method) + } + + private func createPackageManagerFromGithub(for method: InstallationMethod) -> PackageManagerProtocol? { + if case let .sourceBuild(source, instructions) = method { + if let buildTool = source.options["buildTool"] { + switch buildTool { + case "cargo": return CargoPackageManager(installationDirectory: installationDirectory) + case "npm": return NPMPackageManager(installationDirectory: installationDirectory) + case "pip": return PipPackageManager(installationDirectory: installationDirectory) + case "golang": return GolangPackageManager(installationDirectory: installationDirectory) + default: break + } + } + + // If no buildTool option, try to determine from build instructions + for instruction in instructions { + for command in instruction.commands { + if command.contains("cargo ") { + return CargoPackageManager(installationDirectory: installationDirectory) + } else if command.contains("npm ") { + return NPMPackageManager(installationDirectory: installationDirectory) + } else if command.contains("pip ") || command.contains("python ") { + return PipPackageManager(installationDirectory: installationDirectory) + } else if command.contains("go ") { + return GolangPackageManager(installationDirectory: installationDirectory) + } + } + } + + // Check the binary path for clues if needed + let binPath = instructions.first?.binaryPath ?? "" + if binPath.contains("target/release") || binPath.hasSuffix(".rs") { + return CargoPackageManager(installationDirectory: installationDirectory) + } else if binPath.contains("node_modules") { + return NPMPackageManager(installationDirectory: installationDirectory) + } else if binPath.contains(".py") { + return PipPackageManager(installationDirectory: installationDirectory) + } else if binPath.hasSuffix(".go") || binPath.contains("/go/bin") { + return GolangPackageManager(installationDirectory: installationDirectory) + } + } + + // Default to cargo + return CargoPackageManager(installationDirectory: installationDirectory) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift new file mode 100644 index 000000000..d733675e6 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -0,0 +1,172 @@ +// +// CargoPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +class CargoPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + internal let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + try createDirectoryStructure(for: packagePath) + + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + } + + /// Install a package using the new installation method + func install(method: InstallationMethod) async throws { + switch method { + case .standardPackage(let source): + try await installCargoPackage(source) + case let .sourceBuild(source, instructions): + try await buildFromSource(source, instructions) + case .binaryDownload: + throw PackageManagerError.invalidConfiguration + case .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + /// Install a standard cargo package + private func installCargoPackage(_ source: PackageSource) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + + do { + var cargoArgs = ["cargo", "install", "--root", "."] + + // If this is a git-based package + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + cargoArgs.append(contentsOf: ["--git", repoUrl]) + switch gitRef { + case .tag(let tag): + cargoArgs.append(contentsOf: ["--tag", tag]) + case .revision(let rev): + cargoArgs.append(contentsOf: ["--rev", rev]) + case .branch(let branch): + cargoArgs.append(contentsOf: ["--branch", branch]) + } + } else { + // Standard version-based install + cargoArgs.append(contentsOf: ["--version", source.version]) + } + + if let features = source.options["features"] { + cargoArgs.append(contentsOf: ["--features", features]) + } + if source.options["locked"] == "true" { + cargoArgs.append("--locked") + } + + cargoArgs.append(source.name) + let output = try await executeInDirectory(in: packagePath.path, cargoArgs) + print("Successfully installed \(source.name)@\(source.version)") + } catch { + print("Installation failed: \(error)") + throw error + } + } + + /// Build a package from source + private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Building \(source.name) from source in \(packagePath.path)") + + do { + if let repoUrl = source.repositoryUrl { + try createDirectoryStructure(for: packagePath) + + if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { + _ = try await executeInDirectory( + in: packagePath.path, ["git fetch --all"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, ["git clone \(repoUrl) ."] + ) + } + + // Checkout the specific version + _ = try await executeInDirectory( + in: packagePath.path, ["git checkout \(source.version)"] + ) + + // Find the relevant build instruction for this platform + let targetInstructions = instructions.first { + $0.target == "darwin" || $0.target == "unix" + } ?? instructions.first + + guard let buildInstructions = targetInstructions else { + throw PackageManagerError.invalidConfiguration + } + + // Execute each build command + for command in buildInstructions.commands { + _ = try await executeInDirectory(in: packagePath.path, [command]) + } + + // Create bin directory if it doesn't exist + let binPath = packagePath.appendingPathComponent("bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + // Copy the built binary to the bin directory if it's not already there + let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) + let targetBinaryPath = binPath.appendingPathComponent(source.name) + + if builtBinaryPath.path != targetBinaryPath.path && + FileManager.default.fileExists(atPath: builtBinaryPath.path) { + try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) + // Make the binary executable + _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") + } + + print("Successfully built \(source.name) from source") + } else { + throw PackageManagerError.invalidConfiguration + } + } catch { + print("Build failed: \(error)") + 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") + // Check for cargo version output + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.contains("cargo") + } catch { + print("Cargo version check failed: \(error)") + return false + } + } + + internal func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { + let escapedArgs = args.map { arg in + return arg.contains(" ") ? "\"\(arg)\"" : arg + }.joined(separator: " ") + let command = "cd \"\(packagePath)\" && \(escapedArgs)" + return try await runCommand(command) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift new file mode 100644 index 000000000..19f761b00 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -0,0 +1,226 @@ +// +// GolangPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +class GolangPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + internal 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 + } + + try createDirectoryStructure(for: packagePath) + + // For Go, we need to set up a proper module structure + let goModPath = packagePath.appendingPathComponent("go.mod") + if !FileManager.default.fileExists(atPath: goModPath.path) { + let moduleName = "codeedit.temp/placeholder" + _ = try await executeInDirectory( + in: packagePath.path, ["go mod init \(moduleName)"] + ) + } + } + + func install(method: InstallationMethod) async throws { + switch method { + case .standardPackage(let source): + try await installGolangPackage(source) + case let .sourceBuild(source, instructions): + try await buildFromSource(source, instructions) + case .binaryDownload: + throw PackageManagerError.invalidConfiguration + case .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + /// Install a standard Golang package + private func installGolangPackage(_ source: PackageSource) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Installing Go package \(source.name)@\(source.version) in \(packagePath.path)") + + try await initialize(in: packagePath) + + do { + // Check if this is a Git-based package + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + var packageName = source.name + if !packageName.contains("github.com") && !packageName.contains("golang.org") { + packageName = repoUrl.replacingOccurrences(of: "https://", with: "") + } + + // Format the git reference + var gitVersion: String + switch gitRef { + case .tag(let tag): + gitVersion = tag + case .revision(let rev): + gitVersion = rev + case .branch(let branch): + gitVersion = branch + } + + let versionedPackage = "\(packageName)@\(gitVersion)" + _ = try await executeInDirectory( + in: packagePath.path, ["go get \(versionedPackage)"] + ) + } else { + // Standard package installation + let versionedPackage = "\(source.name)@\(source.version)" + _ = try await executeInDirectory( + in: packagePath.path, ["go get \(versionedPackage)"] + ) + } + + // If there's a subpath, build the binary + if let subpath = source.subpath { + let binPath = packagePath.appendingPathComponent("bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + let binaryName = subpath.components(separatedBy: "/").last ?? + source.name.components(separatedBy: "/").last ?? source.name + let buildArgs = ["go", "build", "-o", "bin/\(binaryName)"] + + // If source.name includes the full import path (like github.com/owner/repo) + if source.name.contains("/") { + _ = try await executeInDirectory( + in: packagePath.path, buildArgs + ["\(source.name)/\(subpath)"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, buildArgs + [subpath] + ) + } + let execPath = packagePath.appending(path: "bin").appending(path: binaryName).path + _ = try await runCommand("chmod +x \"\(execPath)\"") + } + + print("Successfully installed \(source.name)@\(source.version)") + } catch { + print("Installation failed: \(error)") + try? cleanupFailedInstallation(packagePath: packagePath) + throw error + } + } + + /// Build a package from source + private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Building \(source.name) from source in \(packagePath.path)") + + do { + if let repoUrl = source.repositoryUrl { + try createDirectoryStructure(for: packagePath) + + if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { + _ = try await executeInDirectory( + in: packagePath.path, ["git fetch --all"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, ["git clone \(repoUrl) ."] + ) + } + + _ = try await executeInDirectory( + in: packagePath.path, ["git checkout \(source.version)"] + ) + + let targetInstructions = instructions.first { + $0.target == "darwin" || $0.target == "unix" + } ?? instructions.first + + guard let buildInstructions = targetInstructions else { + throw PackageManagerError.invalidConfiguration + } + + for command in buildInstructions.commands { + _ = try await executeInDirectory(in: packagePath.path, [command]) + } + + let binPath = packagePath.appendingPathComponent("bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) + let targetBinaryPath = binPath.appendingPathComponent(source.name) + if builtBinaryPath.path != targetBinaryPath.path && + FileManager.default.fileExists(atPath: builtBinaryPath.path) { + try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) + _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") + } + + print("Successfully built \(source.name) from source") + } else { + throw PackageManagerError.invalidConfiguration + } + } catch { + print("Build failed: \(error)") + try? cleanupFailedInstallation(packagePath: packagePath) + throw error + } + } + + /// 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.appendingPathComponent(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) + } + return output.range(of: versionPattern, options: .regularExpression) != nil + } catch { + print("Go version check failed: \(error)") + return false + } + } + + // MARK: - Helper methods + + /// Clean up after a failed installation + private func cleanupFailedInstallation(packagePath: URL) throws { + let goSumPath = packagePath.appendingPathComponent("go.sum") + if FileManager.default.fileExists(atPath: goSumPath.path) { + try FileManager.default.removeItem(at: goSumPath) + } + } + + /// 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) + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift new file mode 100644 index 000000000..c0bd810d3 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -0,0 +1,226 @@ +// +// NPMPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +class NPMPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + internal 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 + } + + // 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.appendingPathComponent(".npmrc") + if !FileManager.default.fileExists(atPath: npmrcPath.path) { + try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) + } + } + + /// Install a package using the new installation method + func install(method: InstallationMethod) async throws { + switch method { + case .standardPackage(let source): + try await installNpmPackage(source) + case let .sourceBuild(source, instructions): + try await buildFromSource(source, instructions) + case .binaryDownload: + throw PackageManagerError.invalidConfiguration + case .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + /// Install a standard npm package + private func installNpmPackage(_ source: PackageSource) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + + try await initialize(in: packagePath) + + do { + // Determine if this is a git-based package + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + // Format the git URL based on the reference type + var gitUrl = repoUrl + switch gitRef { + case .tag(let tag): + gitUrl += "#tag=\(tag)" + case .revision(let rev): + gitUrl += "#\(rev)" + case .branch(let branch): + gitUrl += "#\(branch)" + } + + let installArgs = ["npm", "install", gitUrl] + _ = try await executeInDirectory(in: packagePath.path, installArgs) + + print("Successfully installed \(source.name) from git") + } else { + var installArgs = ["npm", "install", "\(source.name)@\(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(package: source.name, version: source.version) + + print("Successfully installed \(source.name)@\(source.version)") + } + } catch { + print("Installation failed: \(error)") + let nodeModulesPath = packagePath.appendingPathComponent("node_modules").path + try? FileManager.default.removeItem(atPath: nodeModulesPath) + throw error + } + } + + /// Build a package from source + private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Building \(source.name) from source in \(packagePath.path)") + + do { + if let repoUrl = source.repositoryUrl { + try createDirectoryStructure(for: packagePath) + + if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { + _ = try await executeInDirectory( + in: packagePath.path, ["git fetch --all"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, ["git clone \(repoUrl) ."] + ) + } + + _ = try await executeInDirectory( + in: packagePath.path, ["git checkout \(source.version)"] + ) + let targetInstructions = instructions.first { + $0.target == "darwin" || $0.target == "unix" + } ?? instructions.first + + guard let buildInstructions = targetInstructions else { + throw PackageManagerError.invalidConfiguration + } + + // Execute each build command + for command in buildInstructions.commands { + _ = try await executeInDirectory(in: packagePath.path, [command]) + } + + let binPath = packagePath.appendingPathComponent("bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + // Copy the built binary to the bin directory if it's not already there + let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) + let targetBinaryPath = binPath.appendingPathComponent(source.name) + + if builtBinaryPath.path != targetBinaryPath.path && + FileManager.default.fileExists(atPath: builtBinaryPath.path) { + try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) + _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") + } + + print("Successfully built \(source.name) from source") + } else { + throw PackageManagerError.invalidConfiguration + } + } catch { + print("Build failed: \(error)") + 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.appendingPathComponent(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(package: String, version: String) throws { + let packagePath = installationDirectory.appending(path: package) + let packageJsonPath = packagePath.appendingPathComponent("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 + .appendingPathComponent("node_modules") + .appendingPathComponent(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/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift new file mode 100644 index 000000000..0efdd0075 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift @@ -0,0 +1,185 @@ +// +// PackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +protocol PackageManagerProtocol { + var shellClient: ShellClient { get } + + func initialize(in packagePath: URL) async throws + func install(method installationMethod: InstallationMethod) async throws + func getBinaryPath(for package: String) -> String + func isInstalled() async -> Bool +} + +extension PackageManagerProtocol { + /// Creates the directory for the language server to be installed in + internal func createDirectoryStructure(for packagePath: URL) throws { + if !FileManager.default.fileExists(atPath: packagePath.path) { + try FileManager.default.createDirectory( + at: packagePath, + withIntermediateDirectories: true, + attributes: nil + ) + } + } + + /// Executes commands in the specified directory + internal 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 + internal func runCommand(_ command: String) async throws -> [String] { + var output: [String] = [] + for try await line in shellClient.runAsync(command) { + output.append(line) + } + return output + } +} + +enum PackageInstallationStatus: String, Codable { + case inProgress + case completed + case failed +} + +enum PackageManagerError: Error { + case packageManagerNotInstalled + case initializationFailed(String) + case installationFailed(String) + case versionCheckFailed(String) + case invalidConfiguration + case fileSystemError(String) + case processError(String) + case networkError(String) +} + +enum RegistryManagerError: Error { + case invalidResponse(statusCode: Int) + case downloadFailed(url: URL, error: Error) + case maxRetriesExceeded(url: URL, lastError: Error) + case writeFailed(error: Error) +} + +/// Package manager types supported by the system +enum PackageManagerType: String, Codable { + case npm + case cargo + case golang + case pip + case gem + case github + case nuget + case opam + case customBuild + + var executableName: String { + switch self { + case .npm: return "npm" + case .cargo: return "cargo" + case .golang: return "go" + case .pip: return "pip" + case .gem: return "gem" + case .github: return "git" + case .nuget: return "dotnet" + case .opam: return "opam" + case .customBuild: return "sh" + } + } +} + +enum GitReference: Equatable, Codable { + case tag(String) + case revision(String) + case branch(String) +} + +/// Generic package source information that applies to all installation methods +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 name: String + /// Package version + let version: String + /// Optional subpath for packages that specify a specific component or path + let subpath: 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 + let options: [String: String] + + init( + sourceId: String, + type: PackageManagerType, + name: String, + version: String, + subpath: String? = nil, + repositoryUrl: String? = nil, + gitReference: GitReference? = nil, + options: [String: String] = [:] + ) { + self.sourceId = sourceId + self.type = type + self.name = name + self.version = version + self.subpath = subpath + self.repositoryUrl = repositoryUrl + self.gitReference = gitReference + self.options = options + } +} + +/// Build instructions for source-based installations +struct BuildInstructions: Equatable, Codable { + /// Target platform + let target: String + /// Commands to run for building + let commands: [String] + /// Path to the binary after building + let binaryPath: String +} + +/// Installation method enum with all supported types +enum InstallationMethod: Equatable { + /// For standard package manager installations + case standardPackage(source: PackageSource) + /// For packages that need to be built from source with custom build steps + case sourceBuild(source: PackageSource, buildInstructions: [BuildInstructions]) + /// For direct binary downloads (pre-compiled binaries) + case binaryDownload(source: PackageSource, url: String) + /// For installations that aren't supported or recognized + case unknown + + var packageName: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.name + case .unknown: + return nil + } + } + + var packageManagerType: PackageManagerType? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.type + case .unknown: + return nil + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift new file mode 100644 index 000000000..17de868ca --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift @@ -0,0 +1,433 @@ +// +// PackageSourceParser.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +/// Parser for package source IDs +enum PackageSourceParser { + static func parse(_ sourceId: String, buildInstructions: [[String: Any]]? = nil) -> InstallationMethod { + if sourceId.hasPrefix("pkg:cargo/") { + return parseCargoPackage(sourceId) + } else if sourceId.hasPrefix("pkg:npm/") { + return parseNpmPackage(sourceId) + } else if sourceId.hasPrefix("pkg:pypi/") { + return parsePythonPackage(sourceId) + } else if sourceId.hasPrefix("pkg:gem/") { + return parseRubyGem(sourceId) + } else if sourceId.hasPrefix("pkg:golang/") { + return parseGolangPackage(sourceId) + } else if sourceId.hasPrefix("pkg:github/") { + return parseGithubPackage(sourceId, buildInstructions: buildInstructions) + } else { + return .unknown + } + } + + // MARK: - Private parsing methods for each package manager type + + private static func parseCargoPackage(_ sourceId: String) -> InstallationMethod { + // Format: pkg:cargo/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:cargo/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = [:] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else if key == "branch" && value.lowercased() == "true" { + gitReference = .branch(version) + } else { + options[key] = value + } + } + + // If we have a repository URL but no git reference specified, + // default to tag for versions and revision for commit hashes + if repositoryUrl != nil, gitReference == nil { + if version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil { + gitReference = .revision(version) + } else { + gitReference = .tag(version) + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .cargo, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseNpmPackage(_ sourceId: String) -> InstallationMethod { + // Format: pkg:npm/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:npm/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Split into package@version and parameters + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + var packageName: String + var version: String = "latest" + + if packageVersion.contains("@") && !packageVersion.hasPrefix("@") { + // Regular package with version: package@1.0.0 + let parts = packageVersion.split(separator: "@", maxSplits: 1) + packageName = String(parts[0]) + if parts.count > 1 { + version = String(parts[1]) + } + } else if packageVersion.hasPrefix("@") { + // Scoped package: @org/package@1.0.0 + if let atIndex = packageVersion[ + packageVersion.index(after: packageVersion.startIndex)... + ].firstIndex(of: "@") { + packageName = String(packageVersion[.. InstallationMethod { + // Format: pkg:pypi/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:pypi/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = [:] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else if key == "branch" && value.lowercased() == "true" { + gitReference = .branch(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .pip, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseRubyGem(_ sourceId: String) -> InstallationMethod { + // Format: pkg:gem/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:gem/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = [:] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else if key == "branch" && value.lowercased() == "true" { + gitReference = .branch(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .gem, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseGolangPackage(_ sourceId: String) -> InstallationMethod { + // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS + let pkgPrefix = "pkg:golang/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Extract subpath first if present + let subpathComponents = pkgString.split(separator: "#", maxSplits: 1) + let packageVersionParam = String(subpathComponents[0]) + let subpath = subpathComponents.count > 1 ? String(subpathComponents[1]) : nil + + // Then split into package@version and parameters + let components = packageVersionParam.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = [:] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else if key == "branch" && value.lowercased() == "true" { + gitReference = .branch(version) + } else { + options[key] = value + } + } + + // For Go packages, the package name is often also the repository URL + if repositoryUrl == nil { + repositoryUrl = "https://\(packageName)" + } + + let source = PackageSource( + sourceId: sourceId, + type: .golang, + name: packageName, + version: version, + subpath: subpath, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseGithubPackage( + _ sourceId: String, buildInstructions: [[String: Any]]? + ) -> InstallationMethod { + // Format: pkg:github/OWNER/REPO@COMMIT_HASH + let pkgPrefix = "pkg:github/" + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let packagePathVersion = pkgString.split(separator: "@", maxSplits: 1) + guard packagePathVersion.count >= 1 else { return .unknown } + + let packagePath = String(packagePathVersion[0]) + let version = packagePathVersion.count > 1 ? String(packagePathVersion[1]) : "main" + + let pathComponents = packagePath.split(separator: "/") + guard pathComponents.count >= 2 else { return .unknown } + + let owner = String(pathComponents[0]) + let repo = String(pathComponents[1]) + let packageName = repo + let repositoryUrl = "https://github.com/\(owner)/\(repo)" + + let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil + let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) + + var options: [String: String] = [:] + + if let buildInstructions = buildInstructions, !buildInstructions.isEmpty { + // Look at the build commands to determine the build tool + if let firstInstruction = buildInstructions.first, + let runCommands = firstInstruction["run"] as? String { + + if runCommands.contains("cargo ") { + options["buildTool"] = "cargo" + } else if runCommands.contains("npm ") { + options["buildTool"] = "npm" + } else if runCommands.contains("pip ") || runCommands.contains("python ") { + options["buildTool"] = "pip" + } else if runCommands.contains("go ") { + options["buildTool"] = "golang" + } else if runCommands.contains("gem ") { + options["buildTool"] = "gem" + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .github, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + + // Convert build instructions + var instructions: [BuildInstructions] = [] + for instruction in buildInstructions { + guard let target = instruction["target"] as? String, + let runCommands = instruction["run"] as? String, + let bin = instruction["bin"] as? String else { + continue + } + + let commands = runCommands.split(separator: "\n").map { String($0) } + instructions.append(BuildInstructions( + target: target, + commands: commands, + binaryPath: bin + )) + } + return .sourceBuild(source: source, buildInstructions: instructions) + } + + let source = PackageSource( + sourceId: sourceId, + type: .github, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift new file mode 100644 index 000000000..55997aebd --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -0,0 +1,275 @@ +// +// PipPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +class PipPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + internal 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 + } + + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["python -m venv venv"] + ) + + let requirementsPath = packagePath.appendingPathComponent("requirements.txt") + if !FileManager.default.fileExists(atPath: requirementsPath.path) { + try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) + } + } + + func install(method: InstallationMethod) async throws { + switch method { + case .standardPackage(let source): + try await installPythonPackage(source) + case let .sourceBuild(source, instructions): + try await buildFromSource(source, instructions) + case .binaryDownload: + throw PackageManagerError.invalidConfiguration + case .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + /// Install a standard Python package using pip + private func installPythonPackage(_ source: PackageSource) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + + try await initialize(in: packagePath) + + do { + let pipCommand = getPipCommand(in: packagePath) + + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + // Format the git URL based on the reference type + var gitUrl = "git+\(repoUrl)" + switch gitRef { + case .tag(let tag): + gitUrl += "@\(tag)" + case .revision(let rev): + gitUrl += "@\(rev)" + case .branch(let branch): + gitUrl += "@\(branch)" + } + gitUrl += "#egg=\(source.name)" + + let installArgs = [pipCommand, "install", gitUrl] + _ = try await executeInDirectory(in: packagePath.path, installArgs) + + try updateRequirements(packagePath: packagePath, gitUrl: gitUrl) + try await verifyInstallation(packagePath: packagePath, package: source.name) + + print("Successfully installed \(source.name) from git") + } else { + var installArgs = [pipCommand, "install"] + if source.version.lowercased() != "latest" { + installArgs.append("\(source.name)==\(source.version)") + } else { + installArgs.append(source.name) + } + + if let extraIndex = source.options["extra-index-url"] { + installArgs.append(contentsOf: ["--extra-index-url", extraIndex]) + } + if source.options["no-deps"] == "true" { + installArgs.append("--no-deps") + } + + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try updateRequirements(packagePath: packagePath, package: source.name, version: source.version) + try await verifyInstallation(packagePath: packagePath, package: source.name) + + print("Successfully installed \(source.name)@\(source.version)") + } + } catch { + print("Installation failed: \(error)") + throw error + } + } + + /// Build a Python package from source + private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { + let packagePath = installationDirectory.appending(path: source.name) + print("Building \(source.name) from source in \(packagePath.path)") + + do { + if let repoUrl = source.repositoryUrl { + try createDirectoryStructure(for: packagePath) + + if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { + _ = try await executeInDirectory( + in: packagePath.path, ["git fetch --all"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, ["git clone \(repoUrl) ."] + ) + } + + _ = try await executeInDirectory( + in: packagePath.path, ["git checkout \(source.version)"] + ) + let targetInstructions = instructions.first { + $0.target == "darwin" || $0.target == "unix" + } ?? instructions.first + + guard let buildInstructions = targetInstructions else { + throw PackageManagerError.invalidConfiguration + } + + if !FileManager.default.fileExists(atPath: packagePath.appendingPathComponent("venv").path) { + _ = try await executeInDirectory( + in: packagePath.path, ["python -m venv venv"] + ) + } + + // Execute each build command + for command in buildInstructions.commands { + _ = try await executeInDirectory(in: packagePath.path, [command]) + } + + // Create bin directory if it doesn't exist + let binPath = packagePath.appendingPathComponent("bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + // Copy the built binary to the bin directory if it's not already there + let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) + let targetBinaryPath = binPath.appendingPathComponent(source.name) + + if builtBinaryPath.path != targetBinaryPath.path && + FileManager.default.fileExists(atPath: builtBinaryPath.path) { + try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) + _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") + } + + print("Successfully built \(source.name) from source") + } else { + throw PackageManagerError.invalidConfiguration + } + } catch { + print("Build failed: \(error)") + 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 = ["pip --version", "pip3 --version", "python -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.appendingPathComponent(venvPip).path) + ? venvPip + : "python -m pip" + } + + /// Update the requirements.txt file with the installed package + private func updateRequirements(packagePath: URL, package: String, version: String) throws { + let requirementsPath = packagePath.appendingPathComponent("requirements.txt") + var requirementsContent = "" + + if FileManager.default.fileExists(atPath: requirementsPath.path), + let existingContent = try? String(contentsOf: requirementsPath, encoding: .utf8) { + requirementsContent = existingContent + } + + let packageLine = "\(package)==\(version)" + let packagePattern = "^\\s*\(package)\\s*==.*$" + + if let range = requirementsContent.range(of: packagePattern, options: .regularExpression) { + // Replace existing version + requirementsContent.replaceSubrange(range, with: packageLine) + } else { + // Add package to requirements + if !requirementsContent.isEmpty && !requirementsContent.hasSuffix("\n") { + requirementsContent += "\n" + } + requirementsContent += "\(packageLine)\n" + } + + try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) + } + + /// Update the requirements.txt file with a git URL + private func updateRequirements(packagePath: URL, gitUrl: String) throws { + let requirementsPath = packagePath.appendingPathComponent("requirements.txt") + var requirementsContent = "" + + if FileManager.default.fileExists(atPath: requirementsPath.path), + let existingContent = try? String(contentsOf: requirementsPath, encoding: .utf8) { + requirementsContent = existingContent + } + + // Check if git URL is already in requirements + if !requirementsContent.contains(gitUrl) { + if !requirementsContent.isEmpty && !requirementsContent.hasSuffix("\n") { + requirementsContent += "\n" + } + requirementsContent += "\(gitUrl)\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"] + ) + + // Check if the package appears in pip list + let packagePattern = "^\(package)\\s+.*$" + let packageFound = output.contains { line in + line.range(of: packagePattern, options: .regularExpression) != nil + } + + guard packageFound else { + throw PackageManagerError.installationFailed("Package \(package) not found in pip list") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift new file mode 100644 index 000000000..5df1485b1 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -0,0 +1,176 @@ +// +// Registry.swift +// CodeEdit +// +// Created by Abe Malla on 1/29/25. +// + +import Combine +import Foundation +import ZIPFoundation + +final class RegistryManager { + static let shared: RegistryManager = .init() + + private let saveLocation = Settings.shared.baseURL.appending(path: "extensions") + private let registryURL = URL( + string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" + )! + private let checksumURL = URL( + string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" + )! + private var cancellables: Set = [] + + /// Rreference 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 + } + + // 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 + self?.cachedRegistry = nil + self?.cleanupTimer = nil + } + return items + } + + return [] + } + + deinit { + cleanupTimer?.invalidate() + } + + /// Downloads the latest registry and saves to "~/Library/Application Support/CodeEdit/extensions" + func update() async { + async let zipDataTask = download(from: registryURL) + async let checksumsTask = download(from: checksumURL) + + do { + // Make sure the extensions folder exists first + try FileManager.default.createDirectory(at: saveLocation, withIntermediateDirectories: true) + + let (registryData, checksumData) = try await (zipDataTask, checksumsTask) + + let tempZipURL = saveLocation.appending(path: "temp.zip") + let checksumDestination = saveLocation.appending(path: "checksums.txt") + + do { + // Delete existing zip data if it exists + if FileManager.default.fileExists(atPath: tempZipURL.path) { + try FileManager.default.removeItem(at: tempZipURL) + } + let registryJsonPath = saveLocation.appending(path: "registry.json").path + if FileManager.default.fileExists(atPath: registryJsonPath) { + try FileManager.default.removeItem(atPath: registryJsonPath) + } + + // Write the zip data to a temporary file, then unzip + try registryData.write(to: tempZipURL) + try FileManager.default.unzipItem(at: tempZipURL, to: saveLocation) + try FileManager.default.removeItem(at: tempZipURL) + + try checksumData.write(to: checksumDestination) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) + } + } catch { + print("Error details: \(error)") + throw RegistryManagerError.writeFailed(error: error) + } + } catch let error as RegistryManagerError { + switch error { + case .invalidResponse(let statusCode): + print("Invalid response received: \(statusCode)") + case let .downloadFailed(url, error): + print("Download failed for \(url.absoluteString): \(error.localizedDescription)") + case let .maxRetriesExceeded(url, error): + print("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") + case let .writeFailed(error): + print("Failed to write files to disk: \(error.localizedDescription)") + } + } catch { + print("Unexpected registry error: \(error.localizedDescription)") + } + } + + /// Attempts downloading from `url`, with error handling and a retry policy + private func download(from url: URL, attempt: Int = 1) async throws -> Data { + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RegistryManagerError.downloadFailed( + url: url, error: NSError(domain: "Invalid response type", code: -1) + ) + } + guard (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.invalidResponse(statusCode: httpResponse.statusCode) + } + + return data + } catch { + if attempt <= 3 { + let delay = pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + return try await download(from: url, attempt: attempt + 1) + } else { + throw RegistryManagerError.maxRetriesExceeded(url: url, lastError: error) + } + } + } + + /// Loads registry items from disk + private func loadItemsFromDisk() -> [RegistryItem]? { + do { + let registryPath = saveLocation.appending(path: "registry.json") + let registryData = try Data(contentsOf: registryPath) + let decoder = JSONDecoder() + let items = try decoder.decode([RegistryItem].self, from: registryData) + return items.filter { + $0.categories.contains("LSP") + } + } catch { + Task { + await update() + } + return nil + } + } +} + +/// `CachedRegistry` is a timer based cache that will remove the registry items from memory +/// after a certain amount of time. This is because this memory is not needed for the majority of the +/// lifetime of the application and can be freed when no longer used. +private final class CachedRegistry { + let items: [RegistryItem] + let timestamp: Date + + static let expirationInterval: TimeInterval = 300 // 5 minutes + + init(items: [RegistryItem]) { + self.items = items + self.timestamp = Date() + } + + var isExpired: Bool { + Date().timeIntervalSince(timestamp) > Self.expirationInterval + } +} + +extension Notification.Name { + static let RegistryUpdatedNotification = Notification.Name("registryUpdatedNotification") +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift new file mode 100644 index 000000000..f3f8ecd66 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -0,0 +1,149 @@ +// +// RegistryPackage.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]? + + struct Source: Codable { + let id: String + let asset: AssetContainer? + let versionOverrides: [VersionOverride]? + + enum AssetContainer: Codable { + case single(Asset) + case multiple([Asset]) + case simpleFile(String) + case none + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer() { + if let singleValue = try? container.decode(Asset.self) { + self = .single(singleValue) + return + } else if let multipleValues = try? container.decode([Asset].self) { + self = .multiple(multipleValues) + return + } else if let simpleFile = try? container.decode([String: String].self), + simpleFile.count == 1, + simpleFile.keys.contains("file"), + let file = simpleFile["file"] { + self = .simpleFile(file) + return + } + } + self = .none + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + case .simpleFile(let file): + try container.encode(["file": file]) + case .none: + try container.encodeNil() + } + } + } + + struct Asset: Codable { + let target: Target + let file: String? + let bin: BinContainer? + + enum BinContainer: Codable { + case single(String) + case multiple([String: String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let dictValue = try? container.decode([String: String].self) { + self = .multiple(dictValue) + } else { + throw DecodingError.typeMismatch( + BinContainer.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid bin format" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } + } + } + + enum Target: Codable { + case single(String) + case multiple([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let multipleValues = try? container.decode([String].self) { + self = .multiple(multipleValues) + } else { + throw DecodingError.typeMismatch( + Target.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid target format" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.target = try container.decode(Target.self, forKey: .target) + self.file = try container.decodeIfPresent(String.self, forKey: .file) + self.bin = try container.decodeIfPresent(BinContainer.self, forKey: .bin) + } + } + + struct VersionOverride: Codable { + let constraint: String + let id: String + let asset: AssetContainer? + } + } +} diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 2eaab98d6..5110c6c3e 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -218,7 +218,7 @@ final class LSPService: ObservableObject { do { try await languageServer.openDocument(document) } catch { - let uri = await document.languageServerURI + let uri = document.languageServerURI // swiftlint:disable:next line_length self.logger.error("Failed to close document: \(uri ?? "", privacy: .private), language: \(lspLanguage.rawValue). Error \(error)") } diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index 7ba65b699..3435e6fea 100644 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ b/CodeEdit/Features/Settings/Models/SettingsData.swift @@ -50,6 +50,9 @@ struct SettingsData: Codable, Hashable { /// Search Settings var search: SearchSettings = .init() + /// Extension Settings + var extensions: ExtensionSettings = .init() + /// Developer settings for CodeEdit developers var developerSettings: DeveloperSettings = .init() @@ -74,6 +77,9 @@ struct SettingsData: Codable, Hashable { KeybindingsSettings.self, forKey: .keybindings ) ?? .init() + self.extensions = try container.decodeIfPresent( + ExtensionSettings.self, forKey: .extensions + ) ?? .init() self.developerSettings = try container.decodeIfPresent( DeveloperSettings.self, forKey: .developerSettings ) ?? .init() @@ -102,6 +108,8 @@ struct SettingsData: Codable, Hashable { sourceControl.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .location: LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .extensions: + ExtensionSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .developer: developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .behavior: return [.init(name, settingName: "Error")] diff --git a/CodeEdit/Features/Settings/Models/SettingsPage.swift b/CodeEdit/Features/Settings/Models/SettingsPage.swift index ff45c21a0..08d800980 100644 --- a/CodeEdit/Features/Settings/Models/SettingsPage.swift +++ b/CodeEdit/Features/Settings/Models/SettingsPage.swift @@ -32,6 +32,7 @@ struct SettingsPage: Hashable, Equatable, Identifiable { case components = "Components" case location = "Locations" case advanced = "Advanced" + case extensions = "Language Servers" // TODO: CHANGE NAME TO "Extensions" WHEN EXTENSIONS ARE IMPLEMENTED case developer = "Developer" } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift new file mode 100644 index 000000000..f1afa3c26 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift @@ -0,0 +1,137 @@ +// +// ExtensionsSettingsRowView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +struct ExtensionsSettingsRowView: View, Equatable { + let title: String + let subtitle: String + let icon: String + let onCancel: (() -> Void) + + private let cleanedTitle: String + private let cleanedSubtitle: String + + @State private var isHovering: Bool = false + @State private var isInstalling: Bool = false + @State private var isInstalled: Bool = false + @State private var isEnabled = false + @State private var installProgress: Double = 0.0 + + init( + title: String, + subtitle: String, + icon: String, + onCancel: @escaping (() -> Void) + ) { + self.title = title + self.subtitle = subtitle + self.icon = icon + self.onCancel = onCancel + + self.cleanedTitle = title + .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() + } + // Normal capitalization for other words + return str.prefix(1).uppercased() + str.dropFirst() + } + .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) + } + } icon: { + Image(icon) + .resizable() + .aspectRatio(contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .frame(width: 26, height: 26) + .padding(.top, 2) + .padding(.bottom, 2) + .padding(.leading, 2) + } + .opacity(isInstalled && !isEnabled ? 0.5 : 1.0) + + Spacer() + + installationButton() + } + .onHover { hovering in + isHovering = hovering + } + } + + @ViewBuilder + private func installationButton() -> some View { + if isInstalled { + HStack { + if isHovering { + Button { + isInstalling = false + isInstalled = false + } label: { + Text("Remove") + } + } + Toggle("", isOn: $isEnabled) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + } else if isInstalling { + ZStack { + CECircularProgressView(progress: installProgress) + .frame(width: 20, height: 20) + Button { + isInstalling = false + onCancel() + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 8)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } else if isHovering { + Button { + isInstalling = true + withAnimation(.linear(duration: 2)) { + installProgress = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + isInstalling = false + isInstalled = true + isEnabled = true + } + } label: { + Text("Install") + } + } + } + + static func == (lhs: ExtensionsSettingsRowView, rhs: ExtensionsSettingsRowView) -> Bool { + lhs.title == rhs.title && lhs.subtitle == rhs.subtitle + } +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift new file mode 100644 index 000000000..88eb51e92 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift @@ -0,0 +1,52 @@ +// +// ExtensionsSettingsView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +struct ExtensionsSettingsView: View { + @State private var registryItems: [RegistryItem] = [] + @State private var isLoading = true + + var body: some View { + SettingsForm { + if isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + } else { + Section { + List(registryItems, id: \.name) { item in + ExtensionsSettingsRowView( + title: item.name, + subtitle: item.description, + icon: "GitHubIcon", + onCancel: { } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + } + } + } + } + .onAppear { + loadRegistryItems() + } + .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in + loadRegistryItems() + } + } + + private func loadRegistryItems() { + isLoading = true + registryItems = RegistryManager.shared.registryItems + if !registryItems.isEmpty { + isLoading = false + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift new file mode 100644 index 000000000..57df91118 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift @@ -0,0 +1,30 @@ +// +// ExtensionSettings.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +extension SettingsData { + struct ExtensionSettings: Codable, Hashable, SearchableSettingsPage { + + /// The search keys + var searchKeys: [String] { + [ + "Extensions", + "Language Server", + "LSP Binaries", + "Linters", + "Formatters", + "Debug Protocol", + "DAP", + ] + .map { NSLocalizedString($0, comment: "") } + } + + /// Default initializer + init() {} + } +} diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index d7e7ef766..4265f1b44 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -85,6 +85,13 @@ struct SettingsView: View { icon: .system("externaldrive.fill") ) ), + .init( + SettingsPage( + .extensions, + baseColor: Color(hex: "#6A69DC"), // Purple + icon: .system("cube.box.fill") + ) + ), .init( SettingsPage( .developer, @@ -177,6 +184,8 @@ struct SettingsView: View { SourceControlSettingsView() case .location: LocationsSettingsView() + case .extensions: + ExtensionsSettingsView() case .developer: DeveloperSettingsView() default: diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift index 810a463e3..bb95cb3a4 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift @@ -15,7 +15,7 @@ class ShellClient { /// - Parameter args: commands to run /// - Returns: command output func generateProcessAndPipe(_ args: [String]) -> (Process, Pipe) { - var arguments = ["-c"] + var arguments = ["-l", "-c"] arguments.append(contentsOf: args) let task = Process() let pipe = Pipe() @@ -110,6 +110,42 @@ class ShellClient { } } + /// Run a command with AsyncStream + /// - Parameter args: command to run + /// - Returns: async stream of command output + func runAsync(_ args: [String]) -> AsyncThrowingStream { + let (task, pipe) = generateProcessAndPipe(args) + + return AsyncThrowingStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { [unowned pipe] fileHandle in + let data = fileHandle.availableData + if !data.isEmpty { + String(decoding: data, as: UTF8.self) + .split(whereSeparator: \.isNewline) + .forEach({ continuation.yield(String($0)) }) + } else { + if !task.isRunning && task.terminationStatus != 0 { + continuation.finish( + throwing: NSError(domain: "ShellClient", code: Int(task.terminationStatus)) + ) + } else { + continuation.finish() + } + + // Clean up the handler to prevent repeated calls and continuation finishes for the same + // process. + pipe.fileHandleForReading.readabilityHandler = nil + } + } + + do { + try task.run() + } catch { + continuation.finish(throwing: error) + } + } + } + /// Shell client /// - Returns: description static func live() -> ShellClient { diff --git a/CodeEditTests/Features/LSP/Registry.swift b/CodeEditTests/Features/LSP/Registry.swift new file mode 100644 index 000000000..5d04f9ad6 --- /dev/null +++ b/CodeEditTests/Features/LSP/Registry.swift @@ -0,0 +1,68 @@ +// +// Registry.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import XCTest + +@testable import CodeEdit + +final class RegistryTests: XCTestCase { + var registry: RegistryManager = RegistryManager.shared + + // MARK: - Download Tests + + func testRegistryDownload() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let checksumPath = Settings.shared.baseURL.appending(path: "extensions/checksums.txt") + + XCTAssertTrue(FileManager.default.fileExists(atPath: registryJsonPath.path), "Registry JSON file should exist.") + XCTAssertTrue(FileManager.default.fileExists(atPath: checksumPath.path), "Checksum file should exist.") + } + + // MARK: - Decoding Tests + + func testRegistryDecoding() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/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.") + + 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"]) + } else { + XCTFail("Could not find actionlint in registry") + } + } + + func testHandlesVersionOverrides() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let jsonData = try Data(contentsOf: registryJsonPath) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + 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.") + } else { + XCTFail("Could not find ada-language-server to test version overrides") + } + } +} From d8ca6b2944158a438db9919b483db48e5c8c72e1 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 4 Mar 2025 14:50:36 -0800 Subject: [PATCH 02/38] Lint --- .../LSP/Registry/PackageManagers/CargoPackageManager.swift | 2 +- .../Features/LSP/LanguageServer+DocumentTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index d733675e6..833cae02d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -71,7 +71,7 @@ class CargoPackageManager: PackageManagerProtocol { } cargoArgs.append(source.name) - let output = try await executeInDirectory(in: packagePath.path, cargoArgs) + _ = try await executeInDirectory(in: packagePath.path, cargoArgs) print("Successfully installed \(source.name)@\(source.version)") } catch { print("Installation failed: \(error)") diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index e4a726b57..d5bee0c13 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -138,7 +138,7 @@ final class LanguageServerDocumentTests: XCTestCase { CodeEditDocumentController.shared.addDocument(workspace) // Add a CEWorkspaceFile - try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + _ = try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { XCTFail("No File") return @@ -201,7 +201,7 @@ final class LanguageServerDocumentTests: XCTestCase { let (_, fileManager) = try makeTestWorkspace() // Make our example file - try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + _ = try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { XCTFail("No File") return @@ -261,7 +261,7 @@ final class LanguageServerDocumentTests: XCTestCase { let (_, fileManager) = try makeTestWorkspace() // Make our example file - try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + _ = try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { XCTFail("No File") return From ca4831372bcf9acb1bffae59917815e8435e4876 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 4 Mar 2025 16:22:44 -0800 Subject: [PATCH 03/38] Added notification on file open --- CodeEdit.xcodeproj/project.pbxproj | 11 +++++-- .../xcshareddata/swiftpm/Package.resolved | 4 +-- .../LSP/Registry/RegistryManager.swift | 30 +++++++++++++------ .../Features/LSP/Service/LSPService.swift | 29 ++++++++++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 332d539cc..8c3ea1a15 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3924,7 +3924,6 @@ 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, - 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */, 30818CB42D4E563900967860 /* ZIPFoundation */, ); productName = CodeEdit; @@ -4023,8 +4022,8 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + 30C549D82D77BDF8008DDEF8 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -5875,6 +5874,14 @@ minimumVersion = 0.9.19; }; }; + 30C549D82D77BDF8008DDEF8 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; + }; + }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ChimeHQ/LanguageServerProtocol"; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 39a81d808..e7d0ad2cc 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ea2e6949e960b25a9aba20281f3494a3f9e0d44a4fa5330f71e518c196db2141", + "originHash" : "1dde1c6ab99468a252dd82137dee909151a1825b82e7b44a00549b05467d1f37", "pins" : [ { "identity" : "anycodable", @@ -31,7 +31,7 @@ { "identity" : "codeeditsourceeditor", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", + "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor.git", "state" : { "revision" : "6b2c945501f0a5c15d8aa6d159fb2550c391bdd0", "version" : "0.10.0" diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 5df1485b1..322b6d24c 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -135,18 +135,30 @@ final class RegistryManager { /// Loads registry items from disk private func loadItemsFromDisk() -> [RegistryItem]? { + let registryPath = saveLocation.appending(path: "registry.json") + let fileManager = FileManager.default + + // Update the file every 24 hours + let needsUpdate = !fileManager.fileExists(atPath: registryPath.path) || { + guard let attributes = try? fileManager.attributesOfItem(atPath: registryPath.path), + let modificationDate = attributes[.modificationDate] as? Date else { + return true + } + let hoursSinceLastUpdate = Date().timeIntervalSince(modificationDate) / 3600 + return hoursSinceLastUpdate > 24 + }() + + if needsUpdate { + Task { await update() } + return nil + } + do { - let registryPath = saveLocation.appending(path: "registry.json") let registryData = try Data(contentsOf: registryPath) - let decoder = JSONDecoder() - let items = try decoder.decode([RegistryItem].self, from: registryData) - return items.filter { - $0.categories.contains("LSP") - } + let items = try JSONDecoder().decode([RegistryItem].self, from: registryData) + return items.filter { $0.categories.contains("LSP") } } catch { - Task { - await update() - } + Task { await update() } return nil } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 5110c6c3e..eedee22a9 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -11,6 +11,7 @@ import Foundation import LanguageClient import LanguageServerProtocol import CodeEditLanguages +import SwiftUICore /// `LSPService` is a service class responsible for managing the lifecycle and event handling /// of Language Server Protocol (LSP) clients within the CodeEdit application. It handles the initialization, @@ -121,6 +122,9 @@ final class LSPService: ObservableObject { @AppSettings(\.developerSettings.lspBinaries) var lspBinaries + @Environment(\.openWindow) + private var openWindow + init() { // Load the LSP binaries from the developer menu for binary in lspBinaries { @@ -211,6 +215,7 @@ final class LSPService: ObservableObject { languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) } } catch { + notifyToInstallLanguageServer(language: lspLanguage) // swiftlint:disable:next line_length self.logger.error("Failed to find/start server for language: \(lspLanguage.rawValue), workspace: \(workspacePath, privacy: .private)") return @@ -310,6 +315,30 @@ final class LSPService: ObservableObject { } } +extension LSPService { + private func notifyToInstallLanguageServer(language lspLanguage: LanguageIdentifier) { + let lspLanguageTitle = lspLanguage.rawValue.capitalized + let notificationTitle = "Install \(lspLanguageTitle) Language Server" + // Make sure the user doesn't have the same existing notification + guard !NotificationManager.shared.notifications.contains(where: { $0.title == notificationTitle }) else { + return + } + + NotificationManager.shared.post( + iconSymbol: "arrow.down.circle", + iconColor: .clear, + title: notificationTitle, + description: "Install the \(lspLanguageTitle) language server to enable code intelligence features.", + actionButtonTitle: "Install" + ) { [weak self] in + // TODO: Warning: + // Accessing Environment's value outside of being installed on a View. + // This will always read the default value and will not update + self?.openWindow(sceneID: .settings) + } + } +} + // MARK: - Errors enum ServerManagerError: Error { From 80d16d9e277098125b3df281460d266f016f8ece Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 9 Mar 2025 00:08:37 -0800 Subject: [PATCH 04/38] Small update --- .../LSP/Registry/PackageManagerFactory.swift | 167 +++++++++--------- .../LSP/Registry/RegistryManager.swift | 4 + .../LSP/Registry/RegistryPackage.swift | 39 ++++ 3 files changed, 125 insertions(+), 85 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift index 443452142..62d046c9c 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift @@ -9,7 +9,7 @@ import Foundation /// Factory for creating the appropriate package manager based on installation method final class PackageManagerFactory { - let installationDirectory: URL + private let installationDirectory: URL init(installationDirectory: URL) { self.installationDirectory = installationDirectory @@ -37,94 +37,91 @@ final class PackageManagerFactory { } /// Parse a registry entry and create the appropriate installation method - static func parseRegistryEntry(_ entry: [String: Any]) -> InstallationMethod? { - guard let source = entry["source"] as? [String: Any], - let sourceId = source["id"] as? String else { - return nil - } - - let buildInstructions = source["build"] as? [[String: Any]] - - // Detect the build tool from the registry entry - var buildTool: String? - if let bin = entry["bin"] as? [String: String] { - let binValues = Array(bin.values) - if !binValues.isEmpty { - let value = binValues[0] - if value.hasPrefix("cargo:") { - buildTool = "cargo" - } else if value.hasPrefix("npm:") { - buildTool = "npm" - } else if value.hasPrefix("pypi:") { - buildTool = "pip" - } else if value.hasPrefix("gem:") { - buildTool = "gem" - } else if value.hasPrefix("golang:") { - buildTool = "golang" - } - } - } - - var method = PackageSourceParser.parse(sourceId, buildInstructions: buildInstructions) - - if let buildTool = buildTool { - switch method { - case .standardPackage(var source): - var options = source.options - options["buildTool"] = buildTool - source = PackageSource( - sourceId: source.sourceId, - type: source.type, - name: source.name, - version: source.version, - subpath: source.subpath, - repositoryUrl: source.repositoryUrl, - gitReference: source.gitReference, - options: options - ) - method = .standardPackage(source: source) - case .sourceBuild(var source, let instructions): - var options = source.options - options["buildTool"] = buildTool - source = PackageSource( - sourceId: source.sourceId, - type: source.type, - name: source.name, - version: source.version, - subpath: source.subpath, - repositoryUrl: source.repositoryUrl, - gitReference: source.gitReference, - options: options - ) - method = .sourceBuild(source: source, buildInstructions: instructions) - case .binaryDownload(var source, let url): - var options = source.options - options["buildTool"] = buildTool - source = PackageSource( - sourceId: source.sourceId, - type: source.type, - name: source.name, - version: source.version, - subpath: source.subpath, - repositoryUrl: source.repositoryUrl, - gitReference: source.gitReference, - options: options - ) - method = .binaryDownload(source: source, url: url) - case .unknown: - break - } - } - return method + static func parseRegistryEntry(_ entry: RegistryItem) -> InstallationMethod? { +// let buildInstructions = source["build"] as? [[String: Any]] +// entry.source.build +// +// // Detect the build tool from the registry entry +// var buildTool: String? +// if let bin = entry.bin { +// let binValues = Array(bin.values) +// if !binValues.isEmpty { +// let value = binValues[0] +// if value.hasPrefix("cargo:") { +// buildTool = "cargo" +// } else if value.hasPrefix("npm:") { +// buildTool = "npm" +// } else if value.hasPrefix("pypi:") { +// buildTool = "pip" +// } else if value.hasPrefix("gem:") { +// buildTool = "gem" +// } else if value.hasPrefix("golang:") { +// buildTool = "golang" +// } +// } +// } +// +// var method = PackageSourceParser.parse(entry.source.id, buildInstructions: buildInstructions) +// +// if let buildTool = buildTool { +// switch method { +// case .standardPackage(var source): +// var options = source.options +// options["buildTool"] = buildTool +// source = PackageSource( +// sourceId: source.sourceId, +// type: source.type, +// name: source.name, +// version: source.version, +// subpath: source.subpath, +// repositoryUrl: source.repositoryUrl, +// gitReference: source.gitReference, +// options: options +// ) +// method = .standardPackage(source: source) +// case .sourceBuild(var source, let instructions): +// var options = source.options +// options["buildTool"] = buildTool +// source = PackageSource( +// sourceId: source.sourceId, +// type: source.type, +// name: source.name, +// version: source.version, +// subpath: source.subpath, +// repositoryUrl: source.repositoryUrl, +// gitReference: source.gitReference, +// options: options +// ) +// method = .sourceBuild(source: source, buildInstructions: instructions) +// case .binaryDownload(var source, let url): +// var options = source.options +// options["buildTool"] = buildTool +// source = PackageSource( +// sourceId: source.sourceId, +// type: source.type, +// name: source.name, +// version: source.version, +// subpath: source.subpath, +// repositoryUrl: source.repositoryUrl, +// gitReference: source.gitReference, +// options: options +// ) +// method = .binaryDownload(source: source, url: url) +// case .unknown: +// break +// } +// } +// return method + return nil } /// Install a package from a registry entry func installFromRegistryEntry(_ entry: [String: Any]) async throws { - guard let method = PackageManagerFactory.parseRegistryEntry(entry), - let manager = createPackageManager(for: method) else { - throw PackageManagerError.invalidConfiguration - } - try await manager.install(method: method) +// guard let method = PackageManagerFactory.parseRegistryEntry(entry), +// let manager = createPackageManager(for: method) else { +// throw PackageManagerError.invalidConfiguration +// } +// try await manager.install(method: method) } /// Install a package from a source ID string diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 322b6d24c..160a20d83 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -107,6 +107,10 @@ final class RegistryManager { } } +// func installPackage(package entry: RegistryItem) { +// PackageManagerFactory.init(installationDirectory: saveLocation).installFromRegistryEntry(entry) +// } + /// Attempts downloading from `url`, with error handling and a retry policy private func download(from url: URL, attempt: Int = 1) async throws -> Data { do { diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift index f3f8ecd66..82b087386 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -21,6 +21,7 @@ struct RegistryItem: Codable { struct Source: Codable { let id: String let asset: AssetContainer? + let build: BuildContainer? let versionOverrides: [VersionOverride]? enum AssetContainer: Codable { @@ -63,6 +64,44 @@ struct RegistryItem: Codable { } } + enum BuildContainer: Codable { + case single(Build) + case multiple([Build]) + case none + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer() { + if let singleValue = try? container.decode(Build.self) { + self = .single(singleValue) + return + } else if let multipleValues = try? container.decode([Build].self) { + self = .multiple(multipleValues) + return + } + } + self = .none + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + case .none: + try container.encodeNil() + } + } + } + + struct Build: Codable { + let target: String? + let run: String + let env: [String: String]? + let bin: String? + } + struct Asset: Codable { let target: Target let file: String? From 21f7138dc32cdac427e2a5642dbb95e57fd51b02 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 11 Mar 2025 04:26:48 -0700 Subject: [PATCH 05/38] Refactors --- CodeEdit.xcodeproj/project.pbxproj | 3 +- .../LSP/Registry/PackageManagerFactory.swift | 178 ++++------------- .../PackageManagers/CargoPackageManager.swift | 98 +-------- .../GithubPackageManager.swift | 59 ++++++ .../GolangPackageManager.swift | 102 ++-------- .../PackageManagers/NPMPackageManager.swift | 151 ++++---------- .../PackageManagerProtocol.swift | 65 ++---- .../PackageManagers/PackageSourceParser.swift | 165 +++++++--------- .../PackageManagers/PipPackageManager.swift | 187 ++++-------------- .../Registry/RegistryItemTemplateParser.swift | 129 ++++++++++++ .../LSP/Registry/RegistryManager.swift | 18 +- .../LSP/Registry/RegistryPackage.swift | 66 +++++++ 12 files changed, 489 insertions(+), 732 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift create mode 100644 CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 9586d830d..b649bb6c0 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 283BDCBD2972EEBD002AFF81 /* Package.resolved in Resources */ = {isa = PBXBuildFile; fileRef = 283BDCBC2972EEBD002AFF81 /* Package.resolved */; }; 284DC8512978BA2600BF2770 /* .all-contributorsrc in Resources */ = {isa = PBXBuildFile; fileRef = 284DC8502978BA2600BF2770 /* .all-contributorsrc */; }; 2BE487F428245162003F3F64 /* OpenWithCodeEdit.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 30818CB42D4E563900967860 /* ZIPFoundation */; }; 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */; }; 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64932C16CA9100CC8A9E /* LanguageClient */; }; 583E529C29361BAB001AB554 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 583E529B29361BAB001AB554 /* SnapshotTesting */; }; @@ -164,8 +165,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, - 30818CB52D4E563900967860 /* ZIPFoundation in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift index 62d046c9c..9eba9aa66 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift @@ -15,8 +15,37 @@ final class PackageManagerFactory { self.installationDirectory = installationDirectory } + /// Install a package from a registry entry + func installFromRegistryEntry(_ entry: RegistryItem) async throws { + guard let method = Self.parseRegistryEntry(entry), + let manager = createPackageManager(for: method) else { + throw PackageManagerError.invalidConfiguration + } + try await manager.install(method: method) + } + + /// Parse a registry entry and create the appropriate installation method + private 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 + } + } + /// Create the appropriate package manager for the given installation method - func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { + private func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { switch method.packageManagerType { case .npm: return NPMPackageManager(installationDirectory: installationDirectory) @@ -26,154 +55,13 @@ final class PackageManagerFactory { return PipPackageManager(installationDirectory: installationDirectory) case .golang: return GolangPackageManager(installationDirectory: installationDirectory) - case .nuget, .opam, .customBuild, .gem: + case .github, .sourceBuild: + return GithubPackageManager(installationDirectory: installationDirectory) + case .nuget, .opam, .gem, .composer: // TODO: IMPLEMENT OTHER PACKAGE MANAGERS return nil - case .github: - return createPackageManagerFromGithub(for: method) case .none: return nil } } - - /// Parse a registry entry and create the appropriate installation method - static func parseRegistryEntry(_ entry: RegistryItem) -> InstallationMethod? { -// let buildInstructions = source["build"] as? [[String: Any]] -// entry.source.build -// -// // Detect the build tool from the registry entry -// var buildTool: String? -// if let bin = entry.bin { -// let binValues = Array(bin.values) -// if !binValues.isEmpty { -// let value = binValues[0] -// if value.hasPrefix("cargo:") { -// buildTool = "cargo" -// } else if value.hasPrefix("npm:") { -// buildTool = "npm" -// } else if value.hasPrefix("pypi:") { -// buildTool = "pip" -// } else if value.hasPrefix("gem:") { -// buildTool = "gem" -// } else if value.hasPrefix("golang:") { -// buildTool = "golang" -// } -// } -// } -// -// var method = PackageSourceParser.parse(entry.source.id, buildInstructions: buildInstructions) -// -// if let buildTool = buildTool { -// switch method { -// case .standardPackage(var source): -// var options = source.options -// options["buildTool"] = buildTool -// source = PackageSource( -// sourceId: source.sourceId, -// type: source.type, -// name: source.name, -// version: source.version, -// subpath: source.subpath, -// repositoryUrl: source.repositoryUrl, -// gitReference: source.gitReference, -// options: options -// ) -// method = .standardPackage(source: source) -// case .sourceBuild(var source, let instructions): -// var options = source.options -// options["buildTool"] = buildTool -// source = PackageSource( -// sourceId: source.sourceId, -// type: source.type, -// name: source.name, -// version: source.version, -// subpath: source.subpath, -// repositoryUrl: source.repositoryUrl, -// gitReference: source.gitReference, -// options: options -// ) -// method = .sourceBuild(source: source, buildInstructions: instructions) -// case .binaryDownload(var source, let url): -// var options = source.options -// options["buildTool"] = buildTool -// source = PackageSource( -// sourceId: source.sourceId, -// type: source.type, -// name: source.name, -// version: source.version, -// subpath: source.subpath, -// repositoryUrl: source.repositoryUrl, -// gitReference: source.gitReference, -// options: options -// ) -// method = .binaryDownload(source: source, url: url) -// case .unknown: -// break -// } -// } -// return method - return nil - } - - /// Install a package from a registry entry - func installFromRegistryEntry(_ entry: [String: Any]) async throws { -// guard let method = PackageManagerFactory.parseRegistryEntry(entry), -// let manager = createPackageManager(for: method) else { -// throw PackageManagerError.invalidConfiguration -// } -// try await manager.install(method: method) - } - - /// Install a package from a source ID string - func installFromSourceID(_ sourceID: String) async throws { - let method = PackageSourceParser.parse(sourceID) - guard let manager = createPackageManager(for: method) else { - throw PackageManagerError.packageManagerNotInstalled - } - try await manager.install(method: method) - } - - private func createPackageManagerFromGithub(for method: InstallationMethod) -> PackageManagerProtocol? { - if case let .sourceBuild(source, instructions) = method { - if let buildTool = source.options["buildTool"] { - switch buildTool { - case "cargo": return CargoPackageManager(installationDirectory: installationDirectory) - case "npm": return NPMPackageManager(installationDirectory: installationDirectory) - case "pip": return PipPackageManager(installationDirectory: installationDirectory) - case "golang": return GolangPackageManager(installationDirectory: installationDirectory) - default: break - } - } - - // If no buildTool option, try to determine from build instructions - for instruction in instructions { - for command in instruction.commands { - if command.contains("cargo ") { - return CargoPackageManager(installationDirectory: installationDirectory) - } else if command.contains("npm ") { - return NPMPackageManager(installationDirectory: installationDirectory) - } else if command.contains("pip ") || command.contains("python ") { - return PipPackageManager(installationDirectory: installationDirectory) - } else if command.contains("go ") { - return GolangPackageManager(installationDirectory: installationDirectory) - } - } - } - - // Check the binary path for clues if needed - let binPath = instructions.first?.binaryPath ?? "" - if binPath.contains("target/release") || binPath.hasSuffix(".rs") { - return CargoPackageManager(installationDirectory: installationDirectory) - } else if binPath.contains("node_modules") { - return NPMPackageManager(installationDirectory: installationDirectory) - } else if binPath.contains(".py") { - return PipPackageManager(installationDirectory: installationDirectory) - } else if binPath.hasSuffix(".go") || binPath.contains("/go/bin") { - return GolangPackageManager(installationDirectory: installationDirectory) - } - } - - // Default to cargo - return CargoPackageManager(installationDirectory: installationDirectory) - } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index 833cae02d..88745e699 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -18,29 +18,22 @@ class CargoPackageManager: PackageManagerProtocol { } func initialize(in packagePath: URL) async throws { - try createDirectoryStructure(for: packagePath) + do { + try createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } guard await isInstalled() else { throw PackageManagerError.packageManagerNotInstalled } } - /// Install a package using the new installation method func install(method: InstallationMethod) async throws { - switch method { - case .standardPackage(let source): - try await installCargoPackage(source) - case let .sourceBuild(source, instructions): - try await buildFromSource(source, instructions) - case .binaryDownload: - throw PackageManagerError.invalidConfiguration - case .unknown: + guard case .standardPackage(let source) = method else { throw PackageManagerError.invalidConfiguration } - } - /// Install a standard cargo package - private func installCargoPackage(_ source: PackageSource) async throws { let packagePath = installationDirectory.appending(path: source.name) print("Installing \(source.name)@\(source.version) in \(packagePath.path)") @@ -55,11 +48,8 @@ class CargoPackageManager: PackageManagerProtocol { cargoArgs.append(contentsOf: ["--tag", tag]) case .revision(let rev): cargoArgs.append(contentsOf: ["--rev", rev]) - case .branch(let branch): - cargoArgs.append(contentsOf: ["--branch", branch]) } } else { - // Standard version-based install cargoArgs.append(contentsOf: ["--version", source.version]) } @@ -69,8 +59,8 @@ class CargoPackageManager: PackageManagerProtocol { if source.options["locked"] == "true" { cargoArgs.append("--locked") } - cargoArgs.append(source.name) + _ = try await executeInDirectory(in: packagePath.path, cargoArgs) print("Successfully installed \(source.name)@\(source.version)") } catch { @@ -79,71 +69,6 @@ class CargoPackageManager: PackageManagerProtocol { } } - /// Build a package from source - private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { - let packagePath = installationDirectory.appending(path: source.name) - print("Building \(source.name) from source in \(packagePath.path)") - - do { - if let repoUrl = source.repositoryUrl { - try createDirectoryStructure(for: packagePath) - - if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { - _ = try await executeInDirectory( - in: packagePath.path, ["git fetch --all"] - ) - } else { - _ = try await executeInDirectory( - in: packagePath.path, ["git clone \(repoUrl) ."] - ) - } - - // Checkout the specific version - _ = try await executeInDirectory( - in: packagePath.path, ["git checkout \(source.version)"] - ) - - // Find the relevant build instruction for this platform - let targetInstructions = instructions.first { - $0.target == "darwin" || $0.target == "unix" - } ?? instructions.first - - guard let buildInstructions = targetInstructions else { - throw PackageManagerError.invalidConfiguration - } - - // Execute each build command - for command in buildInstructions.commands { - _ = try await executeInDirectory(in: packagePath.path, [command]) - } - - // Create bin directory if it doesn't exist - let binPath = packagePath.appendingPathComponent("bin") - if !FileManager.default.fileExists(atPath: binPath.path) { - try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) - } - - // Copy the built binary to the bin directory if it's not already there - let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) - let targetBinaryPath = binPath.appendingPathComponent(source.name) - - if builtBinaryPath.path != targetBinaryPath.path && - FileManager.default.fileExists(atPath: builtBinaryPath.path) { - try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) - // Make the binary executable - _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") - } - - print("Successfully built \(source.name) from source") - } else { - throw PackageManagerError.invalidConfiguration - } - } catch { - print("Build failed: \(error)") - throw error - } - } - func getBinaryPath(for package: String) -> String { return installationDirectory.appending(path: package).appending(path: "bin").path } @@ -151,7 +76,6 @@ class CargoPackageManager: PackageManagerProtocol { func isInstalled() async -> Bool { do { let versionOutput = try await runCommand("cargo --version") - // Check for cargo version output let output = versionOutput.reduce(into: "") { $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -161,12 +85,4 @@ class CargoPackageManager: PackageManagerProtocol { return false } } - - internal func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { - let escapedArgs = args.map { arg in - return arg.contains(" ") ? "\"\(arg)\"" : arg - }.joined(separator: " ") - let command = "cd \"\(packagePath)\" && \(escapedArgs)" - return try await runCommand(command) - } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift new file mode 100644 index 000000000..5e229d8a0 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -0,0 +1,59 @@ +// +// GithubPackageManager.swift +// LSPInstallTest +// +// Created by Abe Malla on 3/10/25. +// + +import Foundation + +class GithubPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + internal let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { } + + func install(method: InstallationMethod) async throws { + switch method { + case let .binaryDownload(source, url): + downloadBinary(source, url) + break + case let .sourceBuild(source, command): + installFromSource(source, command) + break + 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 { + print("Git version check failed: \(error)") + return false + } + } + + private func downloadBinary(_ source: PackageSource, _ url: String) { + + } + + private func installFromSource(_ source: PackageSource, _ command: String) { + + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index 19f761b00..bb6c803c0 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -21,55 +21,46 @@ class GolangPackageManager: PackageManagerProtocol { throw PackageManagerError.packageManagerNotInstalled } - try createDirectoryStructure(for: packagePath) - - // For Go, we need to set up a proper module structure - let goModPath = packagePath.appendingPathComponent("go.mod") - if !FileManager.default.fileExists(atPath: goModPath.path) { - let moduleName = "codeedit.temp/placeholder" - _ = try await executeInDirectory( - in: packagePath.path, ["go mod init \(moduleName)"] - ) + do { + try createDirectoryStructure(for: packagePath) + + // For Go, we need to set up a proper module structure + let goModPath = packagePath.appendingPathComponent("go.mod") + if !FileManager.default.fileExists(atPath: goModPath.path) { + let moduleName = "codeedit.temp/placeholder" + _ = try await executeInDirectory( + in: packagePath.path, ["go mod init \(moduleName)"] + ) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) } } func install(method: InstallationMethod) async throws { - switch method { - case .standardPackage(let source): - try await installGolangPackage(source) - case let .sourceBuild(source, instructions): - try await buildFromSource(source, instructions) - case .binaryDownload: - throw PackageManagerError.invalidConfiguration - case .unknown: + guard case .standardPackage(let source) = method else { throw PackageManagerError.invalidConfiguration } - } - /// Install a standard Golang package - private func installGolangPackage(_ source: PackageSource) async throws { let packagePath = installationDirectory.appending(path: source.name) print("Installing Go package \(source.name)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) do { - // Check if this is a Git-based package if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + // Check if this is a Git-based package var packageName = source.name if !packageName.contains("github.com") && !packageName.contains("golang.org") { packageName = repoUrl.replacingOccurrences(of: "https://", with: "") } - // Format the git reference var gitVersion: String switch gitRef { case .tag(let tag): gitVersion = tag case .revision(let rev): gitVersion = rev - case .branch(let branch): - gitVersion = branch } let versionedPackage = "\(packageName)@\(gitVersion)" @@ -85,7 +76,7 @@ class GolangPackageManager: PackageManagerProtocol { } // If there's a subpath, build the binary - if let subpath = source.subpath { + if let subpath = source.options["subpath"] { let binPath = packagePath.appendingPathComponent("bin") if !FileManager.default.fileExists(atPath: binPath.path) { try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) @@ -113,66 +104,7 @@ class GolangPackageManager: PackageManagerProtocol { } catch { print("Installation failed: \(error)") try? cleanupFailedInstallation(packagePath: packagePath) - throw error - } - } - - /// Build a package from source - private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { - let packagePath = installationDirectory.appending(path: source.name) - print("Building \(source.name) from source in \(packagePath.path)") - - do { - if let repoUrl = source.repositoryUrl { - try createDirectoryStructure(for: packagePath) - - if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { - _ = try await executeInDirectory( - in: packagePath.path, ["git fetch --all"] - ) - } else { - _ = try await executeInDirectory( - in: packagePath.path, ["git clone \(repoUrl) ."] - ) - } - - _ = try await executeInDirectory( - in: packagePath.path, ["git checkout \(source.version)"] - ) - - let targetInstructions = instructions.first { - $0.target == "darwin" || $0.target == "unix" - } ?? instructions.first - - guard let buildInstructions = targetInstructions else { - throw PackageManagerError.invalidConfiguration - } - - for command in buildInstructions.commands { - _ = try await executeInDirectory(in: packagePath.path, [command]) - } - - let binPath = packagePath.appendingPathComponent("bin") - if !FileManager.default.fileExists(atPath: binPath.path) { - try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) - } - - let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) - let targetBinaryPath = binPath.appendingPathComponent(source.name) - if builtBinaryPath.path != targetBinaryPath.path && - FileManager.default.fileExists(atPath: builtBinaryPath.path) { - try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) - _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") - } - - print("Successfully built \(source.name) from source") - } else { - throw PackageManagerError.invalidConfiguration - } - } catch { - print("Build failed: \(error)") - try? cleanupFailedInstallation(packagePath: packagePath) - throw error + throw PackageManagerError.installationFailed(error.localizedDescription) } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index c0bd810d3..2397b69a4 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -23,83 +23,58 @@ class NPMPackageManager: PackageManagerProtocol { throw PackageManagerError.packageManagerNotInstalled } - // 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) - } + 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"] - ) + // Init npm directory with .npmrc file + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["npm init --yes --scope=codeedit"] + ) - let npmrcPath = packagePath.appendingPathComponent(".npmrc") - if !FileManager.default.fileExists(atPath: npmrcPath.path) { - try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) + let npmrcPath = packagePath.appendingPathComponent(".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 { - switch method { - case .standardPackage(let source): - try await installNpmPackage(source) - case let .sourceBuild(source, instructions): - try await buildFromSource(source, instructions) - case .binaryDownload: - throw PackageManagerError.invalidConfiguration - case .unknown: + guard case .standardPackage(let source) = method else { throw PackageManagerError.invalidConfiguration } - } - /// Install a standard npm package - private func installNpmPackage(_ source: PackageSource) async throws { let packagePath = installationDirectory.appending(path: source.name) print("Installing \(source.name)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) do { - // Determine if this is a git-based package - if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { - // Format the git URL based on the reference type - var gitUrl = repoUrl - switch gitRef { - case .tag(let tag): - gitUrl += "#tag=\(tag)" - case .revision(let rev): - gitUrl += "#\(rev)" - case .branch(let branch): - gitUrl += "#\(branch)" - } - - let installArgs = ["npm", "install", gitUrl] - _ = try await executeInDirectory(in: packagePath.path, installArgs) - - print("Successfully installed \(source.name) from git") - } else { - var installArgs = ["npm", "install", "\(source.name)@\(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)) - } + var installArgs = ["npm", "install", "\(source.name)@\(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(package: source.name, version: source.version) + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try verifyInstallation(package: source.name, version: source.version) - print("Successfully installed \(source.name)@\(source.version)") - } + print("Successfully installed \(source.name)@\(source.version)") } catch { print("Installation failed: \(error)") let nodeModulesPath = packagePath.appendingPathComponent("node_modules").path @@ -108,66 +83,6 @@ class NPMPackageManager: PackageManagerProtocol { } } - /// Build a package from source - private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { - let packagePath = installationDirectory.appending(path: source.name) - print("Building \(source.name) from source in \(packagePath.path)") - - do { - if let repoUrl = source.repositoryUrl { - try createDirectoryStructure(for: packagePath) - - if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { - _ = try await executeInDirectory( - in: packagePath.path, ["git fetch --all"] - ) - } else { - _ = try await executeInDirectory( - in: packagePath.path, ["git clone \(repoUrl) ."] - ) - } - - _ = try await executeInDirectory( - in: packagePath.path, ["git checkout \(source.version)"] - ) - let targetInstructions = instructions.first { - $0.target == "darwin" || $0.target == "unix" - } ?? instructions.first - - guard let buildInstructions = targetInstructions else { - throw PackageManagerError.invalidConfiguration - } - - // Execute each build command - for command in buildInstructions.commands { - _ = try await executeInDirectory(in: packagePath.path, [command]) - } - - let binPath = packagePath.appendingPathComponent("bin") - if !FileManager.default.fileExists(atPath: binPath.path) { - try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) - } - - // Copy the built binary to the bin directory if it's not already there - let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) - let targetBinaryPath = binPath.appendingPathComponent(source.name) - - if builtBinaryPath.path != targetBinaryPath.path && - FileManager.default.fileExists(atPath: builtBinaryPath.path) { - try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) - _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") - } - - print("Successfully built \(source.name) from source") - } else { - throw PackageManagerError.invalidConfiguration - } - } catch { - print("Build failed: \(error)") - throw error - } - } - /// Get the path to the binary func getBinaryPath(for package: String) -> String { let binDirectory = installationDirectory diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift index 0efdd0075..3b61c65f7 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift @@ -43,21 +43,11 @@ extension PackageManagerProtocol { } } -enum PackageInstallationStatus: String, Codable { - case inProgress - case completed - case failed -} - enum PackageManagerError: Error { case packageManagerNotInstalled case initializationFailed(String) case installationFailed(String) - case versionCheckFailed(String) case invalidConfiguration - case fileSystemError(String) - case processError(String) - case networkError(String) } enum RegistryManagerError: Error { @@ -69,38 +59,35 @@ enum RegistryManagerError: Error { /// 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 - case github + /// C# case nuget + /// OCaml case opam - case customBuild - - var executableName: String { - switch self { - case .npm: return "npm" - case .cargo: return "cargo" - case .golang: return "go" - case .pip: return "pip" - case .gem: return "gem" - case .github: return "git" - case .nuget: return "dotnet" - case .opam: return "opam" - case .customBuild: return "sh" - } - } + /// PHP + case composer + /// Building from source + case sourceBuild + /// Binary download + case github } enum GitReference: Equatable, Codable { case tag(String) case revision(String) - case branch(String) } -/// Generic package source information that applies to all installation methods +/// 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 @@ -110,21 +97,18 @@ struct PackageSource: Equatable, Codable { let name: String /// Package version let version: String - /// Optional subpath for packages that specify a specific component or path - let subpath: 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 - let options: [String: String] + var options: [String: String] init( sourceId: String, type: PackageManagerType, name: String, version: String, - subpath: String? = nil, repositoryUrl: String? = nil, gitReference: GitReference? = nil, options: [String: String] = [:] @@ -133,32 +117,21 @@ struct PackageSource: Equatable, Codable { self.type = type self.name = name self.version = version - self.subpath = subpath self.repositoryUrl = repositoryUrl self.gitReference = gitReference self.options = options } } -/// Build instructions for source-based installations -struct BuildInstructions: Equatable, Codable { - /// Target platform - let target: String - /// Commands to run for building - let commands: [String] - /// Path to the binary after building - let binaryPath: String -} - /// Installation method enum with all supported types enum InstallationMethod: Equatable { /// For standard package manager installations case standardPackage(source: PackageSource) /// For packages that need to be built from source with custom build steps - case sourceBuild(source: PackageSource, buildInstructions: [BuildInstructions]) - /// For direct binary downloads (pre-compiled binaries) + case sourceBuild(source: PackageSource, command: String) + /// For direct binary downloads case binaryDownload(source: PackageSource, url: String) - /// For installations that aren't supported or recognized + /// For installations that aren't recognized case unknown var packageName: String? { diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift index 17de868ca..dec02075d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift @@ -9,29 +9,10 @@ import Foundation /// Parser for package source IDs enum PackageSourceParser { - static func parse(_ sourceId: String, buildInstructions: [[String: Any]]? = nil) -> InstallationMethod { - if sourceId.hasPrefix("pkg:cargo/") { - return parseCargoPackage(sourceId) - } else if sourceId.hasPrefix("pkg:npm/") { - return parseNpmPackage(sourceId) - } else if sourceId.hasPrefix("pkg:pypi/") { - return parsePythonPackage(sourceId) - } else if sourceId.hasPrefix("pkg:gem/") { - return parseRubyGem(sourceId) - } else if sourceId.hasPrefix("pkg:golang/") { - return parseGolangPackage(sourceId) - } else if sourceId.hasPrefix("pkg:github/") { - return parseGithubPackage(sourceId, buildInstructions: buildInstructions) - } else { - return .unknown - } - } - - // MARK: - Private parsing methods for each package manager type - - private static func parseCargoPackage(_ sourceId: String) -> InstallationMethod { + static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:cargo/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:cargo/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) @@ -47,7 +28,7 @@ enum PackageSourceParser { let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" // Parse parameters as options - var options: [String: String] = [:] + var options: [String: String] = ["buildTool": "cargo"] var repositoryUrl: String? var gitReference: GitReference? @@ -65,8 +46,6 @@ enum PackageSourceParser { gitReference = .revision(version) } else if key == "tag" && value.lowercased() == "true" { gitReference = .tag(version) - } else if key == "branch" && value.lowercased() == "true" { - gitReference = .branch(version) } else { options[key] = value } @@ -94,9 +73,10 @@ enum PackageSourceParser { return .standardPackage(source: source) } - private static func parseNpmPackage(_ sourceId: String) -> InstallationMethod { + static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:npm/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:npm/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) @@ -131,7 +111,7 @@ enum PackageSourceParser { } // Parse parameters as options - var options: [String: String] = [:] + var options: [String: String] = ["buildTool": "npm"] var repositoryUrl: String? var gitReference: GitReference? @@ -149,8 +129,6 @@ enum PackageSourceParser { gitReference = .revision(version) } else if key == "tag" && value.lowercased() == "true" { gitReference = .tag(version) - } else if key == "branch" && value.lowercased() == "true" { - gitReference = .branch(version) } else { options[key] = value } @@ -168,9 +146,10 @@ enum PackageSourceParser { return .standardPackage(source: source) } - private static func parsePythonPackage(_ sourceId: String) -> InstallationMethod { + static func parsePythonPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:pypi/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:pypi/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) @@ -186,7 +165,7 @@ enum PackageSourceParser { let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" // Parse parameters as options - var options: [String: String] = [:] + var options: [String: String] = ["buildTool": "pip"] var repositoryUrl: String? var gitReference: GitReference? @@ -204,8 +183,6 @@ enum PackageSourceParser { gitReference = .revision(version) } else if key == "tag" && value.lowercased() == "true" { gitReference = .tag(version) - } else if key == "branch" && value.lowercased() == "true" { - gitReference = .branch(version) } else { options[key] = value } @@ -223,9 +200,10 @@ enum PackageSourceParser { return .standardPackage(source: source) } - private static func parseRubyGem(_ sourceId: String) -> InstallationMethod { + static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:gem/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:gem/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) @@ -241,7 +219,7 @@ enum PackageSourceParser { let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" // Parse parameters as options - var options: [String: String] = [:] + var options: [String: String] = ["buildTool": "gem"] var repositoryUrl: String? var gitReference: GitReference? @@ -259,8 +237,6 @@ enum PackageSourceParser { gitReference = .revision(version) } else if key == "tag" && value.lowercased() == "true" { gitReference = .tag(version) - } else if key == "branch" && value.lowercased() == "true" { - gitReference = .branch(version) } else { options[key] = value } @@ -278,9 +254,10 @@ enum PackageSourceParser { return .standardPackage(source: source) } - private static func parseGolangPackage(_ sourceId: String) -> InstallationMethod { + static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS let pkgPrefix = "pkg:golang/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) @@ -302,7 +279,8 @@ enum PackageSourceParser { let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" // Parse parameters as options - var options: [String: String] = [:] + var options: [String: String] = ["buildTool": "golang"] + options["subpath"] = subpath var repositoryUrl: String? var gitReference: GitReference? @@ -320,8 +298,6 @@ enum PackageSourceParser { gitReference = .revision(version) } else if key == "tag" && value.lowercased() == "true" { gitReference = .tag(version) - } else if key == "branch" && value.lowercased() == "true" { - gitReference = .branch(version) } else { options[key] = value } @@ -337,7 +313,6 @@ enum PackageSourceParser { type: .golang, name: packageName, version: version, - subpath: subpath, repositoryUrl: repositoryUrl, gitReference: gitReference, options: options @@ -345,15 +320,13 @@ enum PackageSourceParser { return .standardPackage(source: source) } - private static func parseGithubPackage( - _ sourceId: String, buildInstructions: [[String: Any]]? - ) -> InstallationMethod { + static func parseGithubPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:github/OWNER/REPO@COMMIT_HASH let pkgPrefix = "pkg:github/" + let sourceId = entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) - let packagePathVersion = pkgString.split(separator: "@", maxSplits: 1) guard packagePathVersion.count >= 1 else { return .unknown } @@ -371,63 +344,65 @@ enum PackageSourceParser { let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) - var options: [String: String] = [:] - - if let buildInstructions = buildInstructions, !buildInstructions.isEmpty { - // Look at the build commands to determine the build tool - if let firstInstruction = buildInstructions.first, - let runCommands = firstInstruction["run"] as? String { - - if runCommands.contains("cargo ") { - options["buildTool"] = "cargo" - } else if runCommands.contains("npm ") { - options["buildTool"] = "npm" - } else if runCommands.contains("pip ") || runCommands.contains("python ") { - options["buildTool"] = "pip" - } else if runCommands.contains("go ") { - options["buildTool"] = "golang" - } else if runCommands.contains("gem ") { - options["buildTool"] = "gem" - } - } - - let source = PackageSource( - sourceId: sourceId, - type: .github, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: options - ) - - // Convert build instructions - var instructions: [BuildInstructions] = [] - for instruction in buildInstructions { - guard let target = instruction["target"] as? String, - let runCommands = instruction["run"] as? String, - let bin = instruction["bin"] as? String else { - continue - } - - let commands = runCommands.split(separator: "\n").map { String($0) } - instructions.append(BuildInstructions( - target: target, - commands: commands, - binaryPath: bin - )) - } - return .sourceBuild(source: source, buildInstructions: instructions) + // Is this going to be built from source or downloaded + let isSourceBuild = if case .none? = entry.source.asset { + true + } else { + false } let source = PackageSource( sourceId: sourceId, - type: .github, + type: isSourceBuild ? .sourceBuild : .github, name: packageName, version: version, repositoryUrl: repositoryUrl, - gitReference: gitReference + gitReference: gitReference, + options: [:] ) - return .standardPackage(source: source) + if isSourceBuild { + return parseGithubSourceBuild(source, entry) + } else { + return parseGithubBinaryDownload(source, entry) + } + } + + private static func parseGithubBinaryDownload( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let assetContainer = entry.source.asset, + let repoURL = pkgSource.repositoryUrl, + case .tag(let gitTag) = pkgSource.gitReference, + var fileName = assetContainer.getDarwinFileName(), + !fileName.isEmpty + else { + return .unknown + } + + do { + var registryInfo = try entry.toDictionary() + registryInfo["version"] = pkgSource.version + fileName = try RegistryItemTemplateParser.process( + template: fileName, with: registryInfo + ) + } catch { + return .unknown + } + + let downloadURL = "\(repoURL)/releases/download/\(gitTag)/\(fileName)" + return .binaryDownload(source: pkgSource, url: downloadURL) + } + + private static func parseGithubSourceBuild( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let build = entry.source.build, + var command = build.getUnixBuildCommand() + else { + return .unknown + } + return .sourceBuild(source: pkgSource, command: command) } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index 55997aebd..d33cc2c80 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -22,32 +22,26 @@ class PipPackageManager: PackageManagerProtocol { throw PackageManagerError.packageManagerNotInstalled } - try createDirectoryStructure(for: packagePath) - _ = try await executeInDirectory( - in: packagePath.path, ["python -m venv venv"] - ) - - let requirementsPath = packagePath.appendingPathComponent("requirements.txt") - if !FileManager.default.fileExists(atPath: requirementsPath.path) { - try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) + do { + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["python -m venv venv"] + ) + + let requirementsPath = packagePath.appendingPathComponent("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 { - switch method { - case .standardPackage(let source): - try await installPythonPackage(source) - case let .sourceBuild(source, instructions): - try await buildFromSource(source, instructions) - case .binaryDownload: - throw PackageManagerError.invalidConfiguration - case .unknown: + guard case .standardPackage(let source) = method else { throw PackageManagerError.invalidConfiguration } - } - /// Install a standard Python package using pip - private func installPythonPackage(_ source: PackageSource) async throws { let packagePath = installationDirectory.appending(path: source.name) print("Installing \(source.name)@\(source.version) in \(packagePath.path)") @@ -55,117 +49,33 @@ class PipPackageManager: PackageManagerProtocol { do { let pipCommand = getPipCommand(in: packagePath) + var installArgs = [pipCommand, "install"] - if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { - // Format the git URL based on the reference type - var gitUrl = "git+\(repoUrl)" - switch gitRef { - case .tag(let tag): - gitUrl += "@\(tag)" - case .revision(let rev): - gitUrl += "@\(rev)" - case .branch(let branch): - gitUrl += "@\(branch)" - } - gitUrl += "#egg=\(source.name)" - - let installArgs = [pipCommand, "install", gitUrl] - _ = try await executeInDirectory(in: packagePath.path, installArgs) - - try updateRequirements(packagePath: packagePath, gitUrl: gitUrl) - try await verifyInstallation(packagePath: packagePath, package: source.name) - - print("Successfully installed \(source.name) from git") + if source.version.lowercased() != "latest" { + installArgs.append("\(source.name)==\(source.version)") } else { - var installArgs = [pipCommand, "install"] - if source.version.lowercased() != "latest" { - installArgs.append("\(source.name)==\(source.version)") - } else { - installArgs.append(source.name) - } - - if let extraIndex = source.options["extra-index-url"] { - installArgs.append(contentsOf: ["--extra-index-url", extraIndex]) - } - if source.options["no-deps"] == "true" { - installArgs.append("--no-deps") - } - - _ = try await executeInDirectory(in: packagePath.path, installArgs) - try updateRequirements(packagePath: packagePath, package: source.name, version: source.version) - try await verifyInstallation(packagePath: packagePath, package: source.name) - - print("Successfully installed \(source.name)@\(source.version)") + installArgs.append(source.name) } - } catch { - print("Installation failed: \(error)") - throw error - } - } - - /// Build a Python package from source - private func buildFromSource(_ source: PackageSource, _ instructions: [BuildInstructions]) async throws { - let packagePath = installationDirectory.appending(path: source.name) - print("Building \(source.name) from source in \(packagePath.path)") - - do { - if let repoUrl = source.repositoryUrl { - try createDirectoryStructure(for: packagePath) - - if FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(".git").path) { - _ = try await executeInDirectory( - in: packagePath.path, ["git fetch --all"] - ) - } else { - _ = try await executeInDirectory( - in: packagePath.path, ["git clone \(repoUrl) ."] - ) - } - - _ = try await executeInDirectory( - in: packagePath.path, ["git checkout \(source.version)"] - ) - let targetInstructions = instructions.first { - $0.target == "darwin" || $0.target == "unix" - } ?? instructions.first - guard let buildInstructions = targetInstructions else { - throw PackageManagerError.invalidConfiguration - } - - if !FileManager.default.fileExists(atPath: packagePath.appendingPathComponent("venv").path) { - _ = try await executeInDirectory( - in: packagePath.path, ["python -m venv venv"] - ) - } - - // Execute each build command - for command in buildInstructions.commands { - _ = try await executeInDirectory(in: packagePath.path, [command]) - } - - // Create bin directory if it doesn't exist - let binPath = packagePath.appendingPathComponent("bin") - if !FileManager.default.fileExists(atPath: binPath.path) { - try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + let extras = source.options["extra"] + if let extras = extras { + if let lastIndex = installArgs.indices.last { + installArgs[lastIndex] += "[\(extras)]" } + } - // Copy the built binary to the bin directory if it's not already there - let builtBinaryPath = packagePath.appendingPathComponent(buildInstructions.binaryPath) - let targetBinaryPath = binPath.appendingPathComponent(source.name) + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try updateRequirements( + packagePath: packagePath, + package: source.name, + version: source.version, + extras: extras + ) + try await verifyInstallation(packagePath: packagePath, package: source.name) - if builtBinaryPath.path != targetBinaryPath.path && - FileManager.default.fileExists(atPath: builtBinaryPath.path) { - try FileManager.default.copyItem(at: builtBinaryPath, to: targetBinaryPath) - _ = try await runCommand("chmod +x \"\(targetBinaryPath.path)\"") - } - - print("Successfully built \(source.name) from source") - } else { - throw PackageManagerError.invalidConfiguration - } + print("Successfully installed \(source.name)@\(source.version)") } catch { - print("Build failed: \(error)") + print("Installation failed: \(error)") throw error } } @@ -208,8 +118,8 @@ class PipPackageManager: PackageManagerProtocol { : "python -m pip" } - /// Update the requirements.txt file with the installed package - private func updateRequirements(packagePath: URL, package: String, version: String) throws { + /// Update the requirements.txt file with the installed package and extras + private func updateRequirements(packagePath: URL, package: String, version: String, extras: String? = nil) throws { let requirementsPath = packagePath.appendingPathComponent("requirements.txt") var requirementsContent = "" @@ -218,9 +128,13 @@ class PipPackageManager: PackageManagerProtocol { requirementsContent = existingContent } - let packageLine = "\(package)==\(version)" - let packagePattern = "^\\s*\(package)\\s*==.*$" + var packageLine = "\(package)" + if let extras = extras { + packageLine += "[\(extras)]" + } + packageLine += "==\(version)" + let packagePattern = "^\\s*\(package)(\\[.*\\])?\\s*==.*$" if let range = requirementsContent.range(of: packagePattern, options: .regularExpression) { // Replace existing version requirementsContent.replaceSubrange(range, with: packageLine) @@ -235,27 +149,6 @@ class PipPackageManager: PackageManagerProtocol { try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) } - /// Update the requirements.txt file with a git URL - private func updateRequirements(packagePath: URL, gitUrl: String) throws { - let requirementsPath = packagePath.appendingPathComponent("requirements.txt") - var requirementsContent = "" - - if FileManager.default.fileExists(atPath: requirementsPath.path), - let existingContent = try? String(contentsOf: requirementsPath, encoding: .utf8) { - requirementsContent = existingContent - } - - // Check if git URL is already in requirements - if !requirementsContent.contains(gitUrl) { - if !requirementsContent.isEmpty && !requirementsContent.hasSuffix("\n") { - requirementsContent += "\n" - } - requirementsContent += "\(gitUrl)\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( diff --git a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift new file mode 100644 index 000000000..3ea730950 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift @@ -0,0 +1,129 @@ +// +// RegistryItemTemplateParser.swift +// CodeEdit +// +// Created by Abe Malla on 3/9/25. +// + +import Foundation + +/// This parser is used to parse expressions that may be included in a field of a registry item. +/// +/// Example: +/// "protolint_{{ version | strip_prefix \"v\" }}_darwin_arm64.tar.gz" will be parsed into: +/// protolint_0.53.0_darwin_arm64.tar.gz +enum RegistryItemTemplateParser { + + enum TemplateError: Error { + case invalidFilter(String) + case missingVariable(String) + case invalidPath(String) + case missingKey(String) + } + + private enum Filter { + case stripPrefix(String) + + static func parse(_ filterString: String) throws -> Filter { + let components = filterString.trimmingCharacters(in: .whitespaces).components(separatedBy: " ") + if components.count >= 2 && components[0] == "strip_prefix" { + // Extract the quoted string value + let prefixRaw = components[1] + if prefixRaw.hasPrefix("\"") && prefixRaw.hasSuffix("\"") { + let prefix = String(prefixRaw.dropFirst().dropLast()) + return .stripPrefix(prefix) + } + } + throw TemplateError.invalidFilter(filterString) + } + + func apply(to value: String) -> String { + switch self { + case .stripPrefix(let prefix): + if value.hasPrefix(prefix) { + return String(value.dropFirst(prefix.count)) + } + return value + } + } + } + + static func process(template: String, with context: [String: Any]) throws -> String { + var result = template + + // Find all {{ ... }} patterns + let pattern = "\\{\\{([^\\}]+)\\}\\}" + let regex = try NSRegularExpression(pattern: pattern, options: []) + let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count)) + + // Process matches in reverse order to not invalidate ranges + for match in matches.reversed() { + guard Range(match.range, in: template) != nil else { continue } + + // Extract the content between {{ and }} + let expressionRange = Range(match.range(at: 1), in: template)! + let expression = String(template[expressionRange]) + + // Split by pipe to separate variable path from filters + let components = expression.components(separatedBy: "|").filter { !$0.isEmpty } + let pathExpression = components[0].trimmingCharacters(in: .whitespaces) + let value = try getValueFromPath(pathExpression, in: context) + + // Apply filters + var processedValue = value + if components.count > 1 { + for i in 1.. String { + let pathComponents = path.components(separatedBy: ".") + var currentValue: Any = context + + for component in pathComponents { + if let dict = currentValue as? [String: Any] { + if let value = dict[component] { + currentValue = value + } else { + throw TemplateError.missingKey(component) + } + } else if let array = currentValue as? [Any], let index = Int(component) { + if index >= 0 && index < array.count { + currentValue = array[index] + } else { + throw TemplateError.invalidPath("Array index out of bounds: \(component)") + } + } else { + throw TemplateError.invalidPath("Cannot access component: \(component)") + } + } + + // Convert the final value to a string + if let stringValue = currentValue as? String { + return stringValue + } else if let intValue = currentValue as? Int { + return String(intValue) + } else if let doubleValue = currentValue as? Double { + return String(doubleValue) + } else if let boolValue = currentValue as? Bool { + return String(boolValue) + } else if currentValue is [Any] || currentValue is [String: Any] { + throw TemplateError.invalidPath("Path resolves to a complex object, not a simple value") + } else { + return String(describing: currentValue) + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 160a20d83..941f2f2c2 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -9,10 +9,18 @@ import Combine import Foundation import ZIPFoundation +let homeDirectory = FileManager.default.homeDirectoryForCurrentUser +let installPath = homeDirectory + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("CodeEdit") + .appendingPathComponent("extensions") + + final class RegistryManager { static let shared: RegistryManager = .init() - private let saveLocation = Settings.shared.baseURL.appending(path: "extensions") + private let saveLocation = installPath private let registryURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" )! @@ -107,9 +115,11 @@ final class RegistryManager { } } -// func installPackage(package entry: RegistryItem) { -// PackageManagerFactory.init(installationDirectory: saveLocation).installFromRegistryEntry(entry) -// } + func installPackage(package entry: RegistryItem) async throws { + try await PackageManagerFactory.init( + installationDirectory: saveLocation + ).installFromRegistryEntry(entry) + } /// Attempts downloading from `url`, with error handling and a retry policy private func download(from url: URL, attempt: Int = 1) async throws -> Data { diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift index 82b087386..f56f12b21 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -62,6 +62,29 @@ struct RegistryItem: Codable { try container.encodeNil() } } + + func getDarwinFileName() -> String? { + switch self { + case .single(let asset): + if asset.target.isDarwinTarget() { + return asset.file + } + + case .multiple(let assets): + for asset in assets { + if asset.target.isDarwinTarget() { + return asset.file + } + } + + case .simpleFile(let fileName): + return fileName + + case .none: + return nil + } + return nil + } } enum BuildContainer: Codable { @@ -93,6 +116,22 @@ struct RegistryItem: Codable { try container.encodeNil() } } + + func getUnixBuildCommand() -> String? { + switch self { + case .single(let build): + return build.run + case .multiple(let builds): + for build in builds { + if build.target == "unix" { + return build.run + } + } + case .none: + return nil + } + return nil + } } struct Build: Codable { @@ -169,6 +208,23 @@ struct RegistryItem: Codable { try container.encode(values) } } + + func isDarwinTarget() -> Bool { + switch self { + case .single(let value): +#if arch(arm64) + return value == "darwin" || value == "darwin_arm64" +#else + return value == "darwin" || value == "darwin_x64" +#endif + case .multiple(let values): +#if arch(arm64) + return values.contains("darwin") || values.contains("darwin_arm64") +#else + return values.contains("darwin") || values.contains("darwin_x64") +#endif + } + } } init(from decoder: Decoder) throws { @@ -185,4 +241,14 @@ 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 + } } From c82710845df0911ee321053317b705287b7c3448 Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 11 Mar 2025 04:39:47 -0700 Subject: [PATCH 06/38] Connect install button --- .../ExtensionsSettingsRowView.swift | 15 ++++++++--- .../Extensions/ExtensionsSettingsView.swift | 25 ++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift index f1afa3c26..efe5bc9e6 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift @@ -12,6 +12,7 @@ struct ExtensionsSettingsRowView: View, Equatable { let subtitle: String let icon: String let onCancel: (() -> Void) + let onInstall: (() async -> Void) private let cleanedTitle: String private let cleanedSubtitle: String @@ -26,12 +27,14 @@ struct ExtensionsSettingsRowView: View, Equatable { title: String, subtitle: String, icon: String, - onCancel: @escaping (() -> Void) + onCancel: @escaping (() -> Void), + onInstall: @escaping () async -> Void ) { self.title = title self.subtitle = subtitle self.icon = icon self.onCancel = onCancel + self.onInstall = onInstall self.cleanedTitle = title .replacingOccurrences(of: "-", with: " ") @@ -117,10 +120,14 @@ struct ExtensionsSettingsRowView: View, Equatable { } else if isHovering { Button { isInstalling = true - withAnimation(.linear(duration: 2)) { - installProgress = 1.0 + withAnimation(.linear(duration: 3)) { + installProgress = 0.75 } - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + Task { + await onInstall() + withAnimation(.linear(duration: 1)) { + installProgress = 1.0 + } isInstalling = false isInstalled = true isEnabled = true diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift index 88eb51e92..68e305f84 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift @@ -8,6 +8,8 @@ import SwiftUI struct ExtensionsSettingsView: View { + @State private var didError = false + @State private var installationFailure: InstallationFailure? @State private var registryItems: [RegistryItem] = [] @State private var isLoading = true @@ -27,7 +29,14 @@ struct ExtensionsSettingsView: View { title: item.name, subtitle: item.description, icon: "GitHubIcon", - onCancel: { } + onCancel: { }, + onInstall: { + do { + try await RegistryManager.shared.installPackage(package: item) + } catch { + installationFailure = InstallationFailure(error: error.localizedDescription) + } + } ) .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) } @@ -40,6 +49,15 @@ struct ExtensionsSettingsView: View { .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in loadRegistryItems() } + .alert( + "Installation Failed", + isPresented: $didError, + presenting: installationFailure + ) { _ in + Button("Dismiss") { } + } message: { details in + Text(details.error) + } } private func loadRegistryItems() { @@ -50,3 +68,8 @@ struct ExtensionsSettingsView: View { } } } + +private struct InstallationFailure: Identifiable { + let error: String + let id = UUID() +} From b9d39455f7c5a9555de9934b1473935bb6165eea Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 12 Mar 2025 05:17:04 -0700 Subject: [PATCH 07/38] Refactors --- .../LSP/Registry/PackageManagerFactory.swift | 67 -------- .../PackageManagers/CargoPackageManager.swift | 5 +- .../GithubPackageManager.swift | 57 +++++-- .../GolangPackageManager.swift | 17 +- .../PackageManagerProtocol.swift | 2 +- .../PackageManagers/PackageSourceParser.swift | 6 +- .../PackageManagers/PipPackageManager.swift | 54 +++---- .../Registry/RegistryItemTemplateParser.swift | 10 +- .../LSP/Registry/RegistryManager.swift | 52 ++++++- .../LSP/Registry/RegistryPackage.swift | 147 +++++++++--------- .../Extensions/ExtensionsSettingsView.swift | 2 +- 11 files changed, 212 insertions(+), 207 deletions(-) delete mode 100644 CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift b/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift deleted file mode 100644 index 9eba9aa66..000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagerFactory.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// PackageManagerFactory.swift -// CodeEdit -// -// Created by Abe Malla on 2/3/25. -// - -import Foundation - -/// Factory for creating the appropriate package manager based on installation method -final class PackageManagerFactory { - private let installationDirectory: URL - - init(installationDirectory: URL) { - self.installationDirectory = installationDirectory - } - - /// Install a package from a registry entry - func installFromRegistryEntry(_ entry: RegistryItem) async throws { - guard let method = Self.parseRegistryEntry(entry), - let manager = createPackageManager(for: method) else { - throw PackageManagerError.invalidConfiguration - } - try await manager.install(method: method) - } - - /// Parse a registry entry and create the appropriate installation method - private 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 - } - } - - /// Create the appropriate package manager for the given installation method - private func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { - switch method.packageManagerType { - case .npm: - return NPMPackageManager(installationDirectory: installationDirectory) - case .cargo: - return CargoPackageManager(installationDirectory: installationDirectory) - case .pip: - return PipPackageManager(installationDirectory: installationDirectory) - case .golang: - return GolangPackageManager(installationDirectory: installationDirectory) - case .github, .sourceBuild: - return GithubPackageManager(installationDirectory: installationDirectory) - case .nuget, .opam, .gem, .composer: - // TODO: IMPLEMENT OTHER PACKAGE MANAGERS - return nil - case .none: - return nil - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index 88745e699..7df756001 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -37,6 +37,8 @@ class CargoPackageManager: PackageManagerProtocol { let packagePath = installationDirectory.appending(path: source.name) print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + try await initialize(in: packagePath) + do { var cargoArgs = ["cargo", "install", "--root", "."] @@ -50,7 +52,7 @@ class CargoPackageManager: PackageManagerProtocol { cargoArgs.append(contentsOf: ["--rev", rev]) } } else { - cargoArgs.append(contentsOf: ["--version", source.version]) + cargoArgs.append("\(source.name)@\(source.version)") } if let features = source.options["features"] { @@ -59,7 +61,6 @@ class CargoPackageManager: PackageManagerProtocol { if source.options["locked"] == "true" { cargoArgs.append("--locked") } - cargoArgs.append(source.name) _ = try await executeInDirectory(in: packagePath.path, cargoArgs) print("Successfully installed \(source.name)@\(source.version)") diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index 5e229d8a0..5f88c7f47 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -17,15 +17,32 @@ class GithubPackageManager: PackageManagerProtocol { self.shellClient = .live() } - func initialize(in packagePath: URL) async throws { } + 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 { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.name) + try await initialize(in: packagePath) + switch method { case let .binaryDownload(source, url): - downloadBinary(source, url) + try await downloadBinary(source, url) break case let .sourceBuild(source, command): - installFromSource(source, command) + try await installFromSource(source, command) break case .standardPackage(_), .unknown: throw PackageManagerError.invalidConfiguration @@ -48,12 +65,34 @@ class GithubPackageManager: PackageManagerProtocol { return false } } - - private func downloadBinary(_ source: PackageSource, _ url: String) { - + + private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let fileName = url.lastPathComponent + let downloadPath = installationDirectory.appending(path: source.name) + let packagePath = downloadPath.appending(path: fileName) + + if !FileManager.default.fileExists(atPath: packagePath.path) { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "Coould 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) { - + + private func installFromSource(_ source: PackageSource, _ command: String) async throws { + let installPath = installationDirectory.appending(path: source.name, directoryHint: .isDirectory) + do { + _ = try await executeInDirectory(in: installPath.path, ["git", "clone", source.repositoryUrl!]) + let repoPath = installPath.appending(path: source.name, directoryHint: .isDirectory) + _ = try await executeInDirectory(in: repoPath.path, [command]) + } catch { + print("Failed to build from source: \(error)") + throw PackageManagerError.installationFailed("Source build failed.") + } } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index bb6c803c0..1029ffaac 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -25,7 +25,7 @@ class GolangPackageManager: PackageManagerProtocol { try createDirectoryStructure(for: packagePath) // For Go, we need to set up a proper module structure - let goModPath = packagePath.appendingPathComponent("go.mod") + let goModPath = packagePath.appending(path: "go.mod") if !FileManager.default.fileExists(atPath: goModPath.path) { let moduleName = "codeedit.temp/placeholder" _ = try await executeInDirectory( @@ -48,6 +48,9 @@ class GolangPackageManager: PackageManagerProtocol { try await initialize(in: packagePath) do { + let gobinPath = packagePath.appending(path: "bin", directoryHint: .isDirectory).path + var goInstallCommand = ["env", "GOBIN=\(gobinPath)", "go", "install"] + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { // Check if this is a Git-based package var packageName = source.name @@ -63,17 +66,11 @@ class GolangPackageManager: PackageManagerProtocol { gitVersion = rev } - let versionedPackage = "\(packageName)@\(gitVersion)" - _ = try await executeInDirectory( - in: packagePath.path, ["go get \(versionedPackage)"] - ) + goInstallCommand.append("\(packageName)@\(gitVersion)") } else { - // Standard package installation - let versionedPackage = "\(source.name)@\(source.version)" - _ = try await executeInDirectory( - in: packagePath.path, ["go get \(versionedPackage)"] - ) + goInstallCommand.append("\(source.name)@\(source.version)") } + _ = try await executeInDirectory(in: packagePath.path, goInstallCommand) // If there's a subpath, build the binary if let subpath = source.options["subpath"] { diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift index 3b61c65f7..605940265 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift @@ -130,7 +130,7 @@ enum InstallationMethod: Equatable { /// For packages that need to be built from source with custom build steps case sourceBuild(source: PackageSource, command: String) /// For direct binary downloads - case binaryDownload(source: PackageSource, url: String) + case binaryDownload(source: PackageSource, url: URL) /// For installations that aren't recognized case unknown diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift index dec02075d..a33d1b231 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift @@ -345,7 +345,7 @@ enum PackageSourceParser { let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) // Is this going to be built from source or downloaded - let isSourceBuild = if case .none? = entry.source.asset { + let isSourceBuild = if entry.source.asset == nil { true } else { false @@ -390,7 +390,7 @@ enum PackageSourceParser { return .unknown } - let downloadURL = "\(repoURL)/releases/download/\(gitTag)/\(fileName)" + let downloadURL = URL(string: "\(repoURL)/releases/download/\(gitTag)/\(fileName)")! return .binaryDownload(source: pkgSource, url: downloadURL) } @@ -399,7 +399,7 @@ enum PackageSourceParser { _ entry: RegistryItem ) -> InstallationMethod { guard let build = entry.source.build, - var command = build.getUnixBuildCommand() + let command = build.getUnixBuildCommand() else { return .unknown } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index d33cc2c80..f161aef41 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -65,12 +65,7 @@ class PipPackageManager: PackageManagerProtocol { } _ = try await executeInDirectory(in: packagePath.path, installArgs) - try updateRequirements( - packagePath: packagePath, - package: source.name, - version: source.version, - extras: extras - ) + try await updateRequirements(packagePath: packagePath) try await verifyInstallation(packagePath: packagePath, package: source.name) print("Successfully installed \(source.name)@\(source.version)") @@ -119,46 +114,35 @@ class PipPackageManager: PackageManagerProtocol { } /// Update the requirements.txt file with the installed package and extras - private func updateRequirements(packagePath: URL, package: String, version: String, extras: String? = nil) throws { + private func updateRequirements(packagePath: URL) async throws { + let pipCommand = getPipCommand(in: packagePath) let requirementsPath = packagePath.appendingPathComponent("requirements.txt") - var requirementsContent = "" - if FileManager.default.fileExists(atPath: requirementsPath.path), - let existingContent = try? String(contentsOf: requirementsPath, encoding: .utf8) { - requirementsContent = existingContent - } - - var packageLine = "\(package)" - if let extras = extras { - packageLine += "[\(extras)]" - } - packageLine += "==\(version)" - - let packagePattern = "^\\s*\(package)(\\[.*\\])?\\s*==.*$" - if let range = requirementsContent.range(of: packagePattern, options: .regularExpression) { - // Replace existing version - requirementsContent.replaceSubrange(range, with: packageLine) - } else { - // Add package to requirements - if !requirementsContent.isEmpty && !requirementsContent.hasSuffix("\n") { - requirementsContent += "\n" - } - requirementsContent += "\(packageLine)\n" - } + 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"] + in: packagePath.path, ["\(pipCommand)", "list", "--format=freeze"] ) - // Check if the package appears in pip list - let packagePattern = "^\(package)\\s+.*$" - let packageFound = output.contains { line in - line.range(of: packagePattern, options: .regularExpression) != nil + // 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 { diff --git a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift index 3ea730950..16d171b62 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift @@ -54,7 +54,11 @@ enum RegistryItemTemplateParser { // Find all {{ ... }} patterns let pattern = "\\{\\{([^\\}]+)\\}\\}" let regex = try NSRegularExpression(pattern: pattern, options: []) - let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count)) + let matches = regex.matches( + in: template, + options: [], + range: NSRange(location: 0, length: template.utf16.count) + ) // Process matches in reverse order to not invalidate ranges for match in matches.reversed() { @@ -72,8 +76,8 @@ enum RegistryItemTemplateParser { // Apply filters var processedValue = value if components.count > 1 { - for i in 1.. 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 + } + } + + /// Create the appropriate package manager for the given installation method + private static 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 + } + } } /// `CachedRegistry` is a timer based cache that will remove the registry items from memory diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift index f56f12b21..0a2c1b043 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -123,7 +123,8 @@ struct RegistryItem: Codable { return build.run case .multiple(let builds): for build in builds { - if build.target == "unix" { + guard let target = build.target else { continue } + if target.isDarwinTarget() { return build.run } } @@ -135,10 +136,10 @@ struct RegistryItem: Codable { } struct Build: Codable { - let target: String? + let target: Target? let run: String let env: [String: String]? - let bin: String? + let bin: BinContainer? } struct Asset: Codable { @@ -146,92 +147,96 @@ struct RegistryItem: Codable { let file: String? let bin: BinContainer? - enum BinContainer: Codable { - case single(String) - case multiple([String: String]) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let singleValue = try? container.decode(String.self) { - self = .single(singleValue) - } else if let dictValue = try? container.decode([String: String].self) { - self = .multiple(dictValue) - } else { - throw DecodingError.typeMismatch( - BinContainer.self, - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Invalid bin format" - ) - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .single(let value): - try container.encode(value) - case .multiple(let values): - try container.encode(values) - } - } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.target = try container.decode(Target.self, forKey: .target) + self.file = try container.decodeIfPresent(String.self, forKey: .file) + self.bin = try container.decodeIfPresent(BinContainer.self, forKey: .bin) } + } - enum Target: Codable { - case single(String) - case multiple([String]) + enum Target: Codable { + case single(String) + case multiple([String]) - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let singleValue = try? container.decode(String.self) { - self = .single(singleValue) - } else if let multipleValues = try? container.decode([String].self) { - self = .multiple(multipleValues) - } else { - throw DecodingError.typeMismatch( - Target.self, - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Invalid target format" - ) + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let multipleValues = try? container.decode([String].self) { + self = .multiple(multipleValues) + } else { + throw DecodingError.typeMismatch( + Target.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid target format" ) - } + ) } + } - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .single(let value): - try container.encode(value) - case .multiple(let values): - try container.encode(values) - } + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) } + } - func isDarwinTarget() -> Bool { - switch self { - case .single(let value): + func isDarwinTarget() -> Bool { + switch self { + case .single(let value): #if arch(arm64) - return value == "darwin" || value == "darwin_arm64" + return value == "darwin" || value == "darwin_arm64" || value == "unix" #else - return value == "darwin" || value == "darwin_x64" + return value == "darwin" || value == "darwin_x64" || value == "unix" #endif - case .multiple(let values): + case .multiple(let values): #if arch(arm64) - return values.contains("darwin") || values.contains("darwin_arm64") + return values.contains("darwin") || + values.contains("darwin_arm64") || + values.contains("unix") #else - return values.contains("darwin") || values.contains("darwin_x64") + return values.contains("darwin") || + values.contains("darwin_x64") || + values.contains("unix") #endif - } } } + } + + enum BinContainer: Codable { + case single(String) + case multiple([String: String]) init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.target = try container.decode(Target.self, forKey: .target) - self.file = try container.decodeIfPresent(String.self, forKey: .file) - self.bin = try container.decodeIfPresent(BinContainer.self, forKey: .bin) + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let dictValue = try? container.decode([String: String].self) { + self = .multiple(dictValue) + } else { + throw DecodingError.typeMismatch( + BinContainer.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid bin format" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } } } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift index 68e305f84..66e0202e3 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift @@ -32,7 +32,7 @@ struct ExtensionsSettingsView: View { onCancel: { }, onInstall: { do { - try await RegistryManager.shared.installPackage(package: item) + try await RegistryManager.installPackage(package: item) } catch { installationFailure = InstallationFailure(error: error.localizedDescription) } From 6c9c48a9fef5a9e4534fecd302e3b0375258ad02 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 13 Mar 2025 01:34:51 -0700 Subject: [PATCH 08/38] Fix lint --- .../GithubPackageManager.swift | 6 +- .../GolangPackageManager.swift | 42 +- .../PackageManagers/PackageSourceParser.swift | 408 ------------------ .../PackageSourceParser+Cargo.swift | 72 ++++ .../PackageSourceParser+Gem.swift | 62 +++ .../PackageSourceParser+Golang.swift | 74 ++++ .../PackageSourceParser+NPM.swift | 87 ++++ .../PackageSourceParser+PYPI.swift | 62 +++ .../PackageSourceParser.swift | 97 +++++ .../LSP/Registry/RegistryPackage.swift | 8 +- .../ShellClient/Models/ShellClient.swift | 36 -- 11 files changed, 482 insertions(+), 472 deletions(-) delete mode 100644 CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index 5f88c7f47..57b82b103 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -40,11 +40,9 @@ class GithubPackageManager: PackageManagerProtocol { switch method { case let .binaryDownload(source, url): try await downloadBinary(source, url) - break case let .sourceBuild(source, command): try await installFromSource(source, command) - break - case .standardPackage(_), .unknown: + case .standardPackage, .unknown: throw PackageManagerError.invalidConfiguration } } @@ -67,7 +65,7 @@ class GithubPackageManager: PackageManagerProtocol { } private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { - let (data, _) = try await URLSession.shared.data(from: url) + _ = try await URLSession.shared.data(from: url) let fileName = url.lastPathComponent let downloadPath = installationDirectory.appending(path: source.name) let packagePath = downloadPath.appending(path: fileName) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index 1029ffaac..4bd5a5fb0 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -51,25 +51,7 @@ class GolangPackageManager: PackageManagerProtocol { let gobinPath = packagePath.appending(path: "bin", directoryHint: .isDirectory).path var goInstallCommand = ["env", "GOBIN=\(gobinPath)", "go", "install"] - if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { - // Check if this is a Git-based package - var packageName = source.name - if !packageName.contains("github.com") && !packageName.contains("golang.org") { - packageName = repoUrl.replacingOccurrences(of: "https://", with: "") - } - - var gitVersion: String - switch gitRef { - case .tag(let tag): - gitVersion = tag - case .revision(let rev): - gitVersion = rev - } - - goInstallCommand.append("\(packageName)@\(gitVersion)") - } else { - goInstallCommand.append("\(source.name)@\(source.version)") - } + goInstallCommand.append(getGoInstallCommand(source)) _ = try await executeInDirectory(in: packagePath.path, goInstallCommand) // If there's a subpath, build the binary @@ -152,4 +134,26 @@ class GolangPackageManager: PackageManagerProtocol { 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 + var packageName = source.name + if !packageName.contains("github.com") && !packageName.contains("golang.org") { + packageName = repoUrl.replacingOccurrences(of: "https://", with: "") + } + + var gitVersion: String + switch gitRef { + case .tag(let tag): + gitVersion = tag + case .revision(let rev): + gitVersion = rev + } + + return "\(packageName)@\(gitVersion)" + } else { + return "\(source.name)@\(source.version)" + } + } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift deleted file mode 100644 index a33d1b231..000000000 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageSourceParser.swift +++ /dev/null @@ -1,408 +0,0 @@ -// -// PackageSourceParser.swift -// CodeEdit -// -// Created by Abe Malla on 2/3/25. -// - -import Foundation - -/// Parser for package source IDs -enum PackageSourceParser { - static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { - // Format: pkg:cargo/PACKAGE@VERSION?PARAMS - let pkgPrefix = "pkg:cargo/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - - let components = pkgString.split(separator: "?", maxSplits: 1) - let packageVersion = String(components[0]) - let parameters = components.count > 1 ? String(components[1]) : "" - - let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) - guard packageVersionParts.count >= 1 else { return .unknown } - - let packageName = String(packageVersionParts[0]) - let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" - - // Parse parameters as options - var options: [String: String] = ["buildTool": "cargo"] - var repositoryUrl: String? - var gitReference: GitReference? - - let paramPairs = parameters.split(separator: "&") - for pair in paramPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } - - let key = String(keyValue[0]) - let value = String(keyValue[1]) - - if key == "repository_url" { - repositoryUrl = value - } else if key == "rev" && value.lowercased() == "true" { - gitReference = .revision(version) - } else if key == "tag" && value.lowercased() == "true" { - gitReference = .tag(version) - } else { - options[key] = value - } - } - - // If we have a repository URL but no git reference specified, - // default to tag for versions and revision for commit hashes - if repositoryUrl != nil, gitReference == nil { - if version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil { - gitReference = .revision(version) - } else { - gitReference = .tag(version) - } - } - - let source = PackageSource( - sourceId: sourceId, - type: .cargo, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: options - ) - return .standardPackage(source: source) - } - - static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { - // Format: pkg:npm/PACKAGE@VERSION?PARAMS - let pkgPrefix = "pkg:npm/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - - // Split into package@version and parameters - let components = pkgString.split(separator: "?", maxSplits: 1) - let packageVersion = String(components[0]) - let parameters = components.count > 1 ? String(components[1]) : "" - - var packageName: String - var version: String = "latest" - - if packageVersion.contains("@") && !packageVersion.hasPrefix("@") { - // Regular package with version: package@1.0.0 - let parts = packageVersion.split(separator: "@", maxSplits: 1) - packageName = String(parts[0]) - if parts.count > 1 { - version = String(parts[1]) - } - } else if packageVersion.hasPrefix("@") { - // Scoped package: @org/package@1.0.0 - if let atIndex = packageVersion[ - packageVersion.index(after: packageVersion.startIndex)... - ].firstIndex(of: "@") { - packageName = String(packageVersion[.. InstallationMethod { - // Format: pkg:pypi/PACKAGE@VERSION?PARAMS - let pkgPrefix = "pkg:pypi/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - - let components = pkgString.split(separator: "?", maxSplits: 1) - let packageVersion = String(components[0]) - let parameters = components.count > 1 ? String(components[1]) : "" - - let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) - guard packageVersionParts.count >= 1 else { return .unknown } - - let packageName = String(packageVersionParts[0]) - let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" - - // Parse parameters as options - var options: [String: String] = ["buildTool": "pip"] - var repositoryUrl: String? - var gitReference: GitReference? - - let paramPairs = parameters.split(separator: "&") - for pair in paramPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } - - let key = String(keyValue[0]) - let value = String(keyValue[1]) - - if key == "repository_url" { - repositoryUrl = value - } else if key == "rev" && value.lowercased() == "true" { - gitReference = .revision(version) - } else if key == "tag" && value.lowercased() == "true" { - gitReference = .tag(version) - } else { - options[key] = value - } - } - - let source = PackageSource( - sourceId: sourceId, - type: .pip, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: options - ) - return .standardPackage(source: source) - } - - static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { - // Format: pkg:gem/PACKAGE@VERSION?PARAMS - let pkgPrefix = "pkg:gem/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - - let components = pkgString.split(separator: "?", maxSplits: 1) - let packageVersion = String(components[0]) - let parameters = components.count > 1 ? String(components[1]) : "" - - let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) - guard packageVersionParts.count >= 1 else { return .unknown } - - let packageName = String(packageVersionParts[0]) - let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" - - // Parse parameters as options - var options: [String: String] = ["buildTool": "gem"] - var repositoryUrl: String? - var gitReference: GitReference? - - let paramPairs = parameters.split(separator: "&") - for pair in paramPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } - - let key = String(keyValue[0]) - let value = String(keyValue[1]) - - if key == "repository_url" { - repositoryUrl = value - } else if key == "rev" && value.lowercased() == "true" { - gitReference = .revision(version) - } else if key == "tag" && value.lowercased() == "true" { - gitReference = .tag(version) - } else { - options[key] = value - } - } - - let source = PackageSource( - sourceId: sourceId, - type: .gem, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: options - ) - return .standardPackage(source: source) - } - - static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { - // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS - let pkgPrefix = "pkg:golang/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - - // Extract subpath first if present - let subpathComponents = pkgString.split(separator: "#", maxSplits: 1) - let packageVersionParam = String(subpathComponents[0]) - let subpath = subpathComponents.count > 1 ? String(subpathComponents[1]) : nil - - // Then split into package@version and parameters - let components = packageVersionParam.split(separator: "?", maxSplits: 1) - let packageVersion = String(components[0]) - let parameters = components.count > 1 ? String(components[1]) : "" - - let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) - guard packageVersionParts.count >= 1 else { return .unknown } - - let packageName = String(packageVersionParts[0]) - let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" - - // Parse parameters as options - var options: [String: String] = ["buildTool": "golang"] - options["subpath"] = subpath - var repositoryUrl: String? - var gitReference: GitReference? - - let paramPairs = parameters.split(separator: "&") - for pair in paramPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } - - let key = String(keyValue[0]) - let value = String(keyValue[1]) - - if key == "repository_url" { - repositoryUrl = value - } else if key == "rev" && value.lowercased() == "true" { - gitReference = .revision(version) - } else if key == "tag" && value.lowercased() == "true" { - gitReference = .tag(version) - } else { - options[key] = value - } - } - - // For Go packages, the package name is often also the repository URL - if repositoryUrl == nil { - repositoryUrl = "https://\(packageName)" - } - - let source = PackageSource( - sourceId: sourceId, - type: .golang, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: options - ) - return .standardPackage(source: source) - } - - static func parseGithubPackage(_ entry: RegistryItem) -> InstallationMethod { - // Format: pkg:github/OWNER/REPO@COMMIT_HASH - let pkgPrefix = "pkg:github/" - let sourceId = entry.source.id - guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } - - let pkgString = sourceId.dropFirst(pkgPrefix.count) - let packagePathVersion = pkgString.split(separator: "@", maxSplits: 1) - guard packagePathVersion.count >= 1 else { return .unknown } - - let packagePath = String(packagePathVersion[0]) - let version = packagePathVersion.count > 1 ? String(packagePathVersion[1]) : "main" - - let pathComponents = packagePath.split(separator: "/") - guard pathComponents.count >= 2 else { return .unknown } - - let owner = String(pathComponents[0]) - let repo = String(pathComponents[1]) - let packageName = repo - let repositoryUrl = "https://github.com/\(owner)/\(repo)" - - let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil - let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) - - // Is this going to be built from source or downloaded - let isSourceBuild = if entry.source.asset == nil { - true - } else { - false - } - - let source = PackageSource( - sourceId: sourceId, - type: isSourceBuild ? .sourceBuild : .github, - name: packageName, - version: version, - repositoryUrl: repositoryUrl, - gitReference: gitReference, - options: [:] - ) - if isSourceBuild { - return parseGithubSourceBuild(source, entry) - } else { - return parseGithubBinaryDownload(source, entry) - } - } - - private static func parseGithubBinaryDownload( - _ pkgSource: PackageSource, - _ entry: RegistryItem - ) -> InstallationMethod { - guard let assetContainer = entry.source.asset, - let repoURL = pkgSource.repositoryUrl, - case .tag(let gitTag) = pkgSource.gitReference, - var fileName = assetContainer.getDarwinFileName(), - !fileName.isEmpty - else { - return .unknown - } - - do { - var registryInfo = try entry.toDictionary() - registryInfo["version"] = pkgSource.version - fileName = try RegistryItemTemplateParser.process( - template: fileName, with: registryInfo - ) - } catch { - return .unknown - } - - let downloadURL = URL(string: "\(repoURL)/releases/download/\(gitTag)/\(fileName)")! - return .binaryDownload(source: pkgSource, url: downloadURL) - } - - private static func parseGithubSourceBuild( - _ pkgSource: PackageSource, - _ entry: RegistryItem - ) -> InstallationMethod { - guard let build = entry.source.build, - let command = build.getUnixBuildCommand() - else { - return .unknown - } - return .sourceBuild(source: pkgSource, command: command) - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift new file mode 100644 index 000000000..3064ae822 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -0,0 +1,72 @@ +// +// PackageSourceParser+Cargo.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:cargo/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:cargo/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "cargo"] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + // If we have a repository URL but no git reference specified, + // default to tag for versions and revision for commit hashes + if repositoryUrl != nil, gitReference == nil { + if version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil { + gitReference = .revision(version) + } else { + gitReference = .tag(version) + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .cargo, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift new file mode 100644 index 000000000..c194e2363 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -0,0 +1,62 @@ +// +// PackageSourceParser+Gem.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:gem/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:gem/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "gem"] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .gem, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift new file mode 100644 index 000000000..555b85018 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -0,0 +1,74 @@ +// +// PackageSourceParser+Golang.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS + let pkgPrefix = "pkg:golang/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Extract subpath first if present + let subpathComponents = pkgString.split(separator: "#", maxSplits: 1) + let packageVersionParam = String(subpathComponents[0]) + let subpath = subpathComponents.count > 1 ? String(subpathComponents[1]) : nil + + // Then split into package@version and parameters + let components = packageVersionParam.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "golang"] + options["subpath"] = subpath + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + // For Go packages, the package name is often also the repository URL + if repositoryUrl == nil { + repositoryUrl = "https://\(packageName)" + } + + let source = PackageSource( + sourceId: sourceId, + type: .golang, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift new file mode 100644 index 000000000..c68ca0a5b --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -0,0 +1,87 @@ +// +// PackageSourceParser+NPM.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:npm/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:npm/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Split into package@version and parameters + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let (packageName, version) = parseNPMPackageVersion(packageVersion) + + // Parse parameters as options + var options: [String: String] = ["buildTool": "npm"] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .npm, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseNPMPackageVersion(_ packageVersion: String) -> (String, String) { + var packageName: String + var version: String = "latest" + + if packageVersion.contains("@") && !packageVersion.hasPrefix("@") { + // Regular package with version: package@1.0.0 + let parts = packageVersion.split(separator: "@", maxSplits: 1) + packageName = String(parts[0]) + if parts.count > 1 { + version = String(parts[1]) + } + } else if packageVersion.hasPrefix("@") { + // Scoped package: @org/package@1.0.0 + if let atIndex = packageVersion[ + packageVersion.index(after: packageVersion.startIndex)... + ].firstIndex(of: "@") { + packageName = String(packageVersion[.. InstallationMethod { + // Format: pkg:pypi/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:pypi/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "pip"] + var repositoryUrl: String? + var gitReference: GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .pip, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift new file mode 100644 index 000000000..23ef6d19f --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -0,0 +1,97 @@ +// +// PackageSourceParser.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +/// Parser for package source IDs +enum PackageSourceParser { + static func parseGithubPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:github/OWNER/REPO@COMMIT_HASH + let pkgPrefix = "pkg:github/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + let packagePathVersion = pkgString.split(separator: "@", maxSplits: 1) + guard packagePathVersion.count >= 1 else { return .unknown } + + let packagePath = String(packagePathVersion[0]) + let version = packagePathVersion.count > 1 ? String(packagePathVersion[1]) : "main" + + let pathComponents = packagePath.split(separator: "/") + guard pathComponents.count >= 2 else { return .unknown } + + let owner = String(pathComponents[0]) + let repo = String(pathComponents[1]) + let packageName = repo + let repositoryUrl = "https://github.com/\(owner)/\(repo)" + + let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil + let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) + + // Is this going to be built from source or downloaded + let isSourceBuild = if entry.source.asset == nil { + true + } else { + false + } + + let source = PackageSource( + sourceId: sourceId, + type: isSourceBuild ? .sourceBuild : .github, + name: packageName, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: [:] + ) + if isSourceBuild { + return parseGithubSourceBuild(source, entry) + } else { + return parseGithubBinaryDownload(source, entry) + } + } + + private static func parseGithubBinaryDownload( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let assetContainer = entry.source.asset, + let repoURL = pkgSource.repositoryUrl, + case .tag(let gitTag) = pkgSource.gitReference, + var fileName = assetContainer.getDarwinFileName(), + !fileName.isEmpty + else { + return .unknown + } + + do { + var registryInfo = try entry.toDictionary() + registryInfo["version"] = pkgSource.version + fileName = try RegistryItemTemplateParser.process( + template: fileName, with: registryInfo + ) + } catch { + return .unknown + } + + let downloadURL = URL(string: "\(repoURL)/releases/download/\(gitTag)/\(fileName)")! + return .binaryDownload(source: pkgSource, url: downloadURL) + } + + private static func parseGithubSourceBuild( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let build = entry.source.build, + let command = build.getUnixBuildCommand() + else { + return .unknown + } + return .sourceBuild(source: pkgSource, command: command) + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift index 0a2c1b043..f610d349f 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -62,7 +62,7 @@ struct RegistryItem: Codable { try container.encodeNil() } } - + func getDarwinFileName() -> String? { switch self { case .single(let asset): @@ -71,10 +71,8 @@ struct RegistryItem: Codable { } case .multiple(let assets): - for asset in assets { - if asset.target.isDarwinTarget() { - return asset.file - } + for asset in assets where asset.target.isDarwinTarget() { + return asset.file } case .simpleFile(let fileName): diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift index 75803ad3a..baf99e7cf 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift @@ -125,42 +125,6 @@ class ShellClient { } } - /// Run a command with AsyncStream - /// - Parameter args: command to run - /// - Returns: async stream of command output - func runAsync(_ args: [String]) -> AsyncThrowingStream { - let (task, pipe) = generateProcessAndPipe(args) - - return AsyncThrowingStream { continuation in - pipe.fileHandleForReading.readabilityHandler = { [unowned pipe] fileHandle in - let data = fileHandle.availableData - if !data.isEmpty { - String(decoding: data, as: UTF8.self) - .split(whereSeparator: \.isNewline) - .forEach({ continuation.yield(String($0)) }) - } else { - if !task.isRunning && task.terminationStatus != 0 { - continuation.finish( - throwing: NSError(domain: "ShellClient", code: Int(task.terminationStatus)) - ) - } else { - continuation.finish() - } - - // Clean up the handler to prevent repeated calls and continuation finishes for the same - // process. - pipe.fileHandleForReading.readabilityHandler = nil - } - } - - do { - try task.run() - } catch { - continuation.finish(throwing: error) - } - } - } - /// Shell client /// - Returns: description static func live() -> ShellClient { From ee93abb5d680ad5b56c4a45fe51286b73d203222 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 13 Mar 2025 05:34:33 -0700 Subject: [PATCH 09/38] Refactors, added settings loading --- .../PackageManagerProtocol.swift | 14 ++- .../GolangPackageManager.swift | 6 +- .../PackageManagers/NPMPackageManager.swift | 12 +- .../PackageManagers/PipPackageManager.swift | 6 +- .../PackageSourceParser+Cargo.swift | 2 +- .../PackageSourceParser+Gem.swift | 2 +- .../PackageSourceParser+Golang.swift | 2 +- .../PackageSourceParser+NPM.swift | 2 +- .../LSP/Registry/RegistryManager.swift | 19 ++- ...View.swift => LanguageServerRowView.swift} | 117 +++++++++++------- ...gsView.swift => LanguageServersView.swift} | 10 +- .../Extensions/Models/ExtensionSettings.swift | 22 +++- CodeEdit/Features/Settings/SettingsView.swift | 2 +- 13 files changed, 140 insertions(+), 76 deletions(-) rename CodeEdit/Features/LSP/Registry/{PackageManagers => }/PackageManagerProtocol.swift (91%) rename CodeEdit/Features/Settings/Pages/Extensions/{ExtensionsSettingsRowView.swift => LanguageServerRowView.swift} (55%) rename CodeEdit/Features/Settings/Pages/Extensions/{ExtensionsSettingsView.swift => LanguageServersView.swift} (81%) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift similarity index 91% rename from CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift rename to CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift index 605940265..95a948c4f 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -19,7 +19,8 @@ protocol PackageManagerProtocol { extension PackageManagerProtocol { /// Creates the directory for the language server to be installed in internal func createDirectoryStructure(for packagePath: URL) throws { - if !FileManager.default.fileExists(atPath: packagePath.path) { + let decodedPath = packagePath.path.removingPercentEncoding ?? packagePath.path + if !FileManager.default.fileExists(atPath: decodedPath) { try FileManager.default.createDirectory( at: packagePath, withIntermediateDirectories: true, @@ -145,6 +146,17 @@ enum InstallationMethod: Equatable { } } + var version: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.version + case .unknown: + return nil + } + } + var packageManagerType: PackageManagerType? { switch self { case .standardPackage(let source), diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index 4bd5a5fb0..0ec1aef99 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -56,7 +56,7 @@ class GolangPackageManager: PackageManagerProtocol { // If there's a subpath, build the binary if let subpath = source.options["subpath"] { - let binPath = packagePath.appendingPathComponent("bin") + let binPath = packagePath.appending(path: "bin") if !FileManager.default.fileExists(atPath: binPath.path) { try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) } @@ -91,7 +91,7 @@ class GolangPackageManager: PackageManagerProtocol { 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.appendingPathComponent(binaryName).path + let specificBinPath = binPath.appending(path: binaryName).path if FileManager.default.fileExists(atPath: specificBinPath) { return specificBinPath } @@ -117,7 +117,7 @@ class GolangPackageManager: PackageManagerProtocol { /// Clean up after a failed installation private func cleanupFailedInstallation(packagePath: URL) throws { - let goSumPath = packagePath.appendingPathComponent("go.sum") + let goSumPath = packagePath.appending(path: "go.sum") if FileManager.default.fileExists(atPath: goSumPath.path) { try FileManager.default.removeItem(at: goSumPath) } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index 2397b69a4..4e8fc7533 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -40,7 +40,7 @@ class NPMPackageManager: PackageManagerProtocol { in: packagePath.path, ["npm init --yes --scope=codeedit"] ) - let npmrcPath = packagePath.appendingPathComponent(".npmrc") + let npmrcPath = packagePath.appending(path: ".npmrc") if !FileManager.default.fileExists(atPath: npmrcPath.path) { try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) } @@ -77,7 +77,7 @@ class NPMPackageManager: PackageManagerProtocol { print("Successfully installed \(source.name)@\(source.version)") } catch { print("Installation failed: \(error)") - let nodeModulesPath = packagePath.appendingPathComponent("node_modules").path + let nodeModulesPath = packagePath.appending(path: "node_modules").path try? FileManager.default.removeItem(atPath: nodeModulesPath) throw error } @@ -89,7 +89,7 @@ class NPMPackageManager: PackageManagerProtocol { .appending(path: package) .appending(path: "node_modules") .appending(path: ".bin") - return binDirectory.appendingPathComponent(package).path + return binDirectory.appending(path: package).path } /// Checks if npm is installed @@ -109,7 +109,7 @@ class NPMPackageManager: PackageManagerProtocol { /// Verify the installation was successful private func verifyInstallation(package: String, version: String) throws { let packagePath = installationDirectory.appending(path: package) - let packageJsonPath = packagePath.appendingPathComponent("package.json").path + let packageJsonPath = packagePath.appending(path: "package.json").path // Verify package.json contains the installed package guard let packageJsonData = FileManager.default.contents(atPath: packageJsonPath), @@ -132,8 +132,8 @@ class NPMPackageManager: PackageManagerProtocol { // Verify the package exists in node_modules let packageDirectory = packagePath - .appendingPathComponent("node_modules") - .appendingPathComponent(package) + .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 index f161aef41..ba199572b 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -28,7 +28,7 @@ class PipPackageManager: PackageManagerProtocol { in: packagePath.path, ["python -m venv venv"] ) - let requirementsPath = packagePath.appendingPathComponent("requirements.txt") + 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) } @@ -108,7 +108,7 @@ class PipPackageManager: PackageManagerProtocol { private func getPipCommand(in packagePath: URL) -> String { let venvPip = "venv/bin/pip" - return FileManager.default.fileExists(atPath: packagePath.appendingPathComponent(venvPip).path) + return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) ? venvPip : "python -m pip" } @@ -116,7 +116,7 @@ class PipPackageManager: PackageManagerProtocol { /// 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.appendingPathComponent("requirements.txt") + let requirementsPath = packagePath.appending(path: "requirements.txt") let freezeOutput = try await executeInDirectory( in: packagePath.path, diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift index 3064ae822..e6b1efe70 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -9,7 +9,7 @@ extension PackageSourceParser { static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:cargo/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:cargo/" - let sourceId = entry.source.id + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift index c194e2363..e8898a972 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -9,7 +9,7 @@ extension PackageSourceParser { static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:gem/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:gem/" - let sourceId = entry.source.id + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift index 555b85018..67abc7dfb 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -9,7 +9,7 @@ extension PackageSourceParser { static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS let pkgPrefix = "pkg:golang/" - let sourceId = entry.source.id + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift index c68ca0a5b..46636bebf 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -9,7 +9,7 @@ extension PackageSourceParser { static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { // Format: pkg:npm/PACKAGE@VERSION?PARAMS let pkgPrefix = "pkg:npm/" - let sourceId = entry.source.id + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } let pkgString = sourceId.dropFirst(pkgPrefix.count) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 7fcfea702..4874934b8 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -11,10 +11,10 @@ import ZIPFoundation let homeDirectory = FileManager.default.homeDirectoryForCurrentUser let installPath = homeDirectory - .appendingPathComponent("Library") - .appendingPathComponent("Application Support") - .appendingPathComponent("CodeEdit") - .appendingPathComponent("extensions") + .appending(path: "Library") + .appending(path: "Application Support") + .appending(path: "CodeEdit") + .appending(path: "extensions") final class RegistryManager { static let shared: RegistryManager = .init() @@ -56,6 +56,9 @@ final class RegistryManager { return [] } + @AppSettings(\.extensions.installedLanguageServers) + var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + deinit { cleanupTimer?.invalidate() } @@ -114,12 +117,18 @@ final class RegistryManager { } } - static func installPackage(package entry: RegistryItem) async throws { + func installPackage(package entry: RegistryItem) async throws { let method = Self.parseRegistryEntry(entry) guard let manager = Self.createPackageManager(for: method) else { throw PackageManagerError.invalidConfiguration } try await manager.install(method: method) + + installedLanguageServers[entry.name] = .init( + packageName: entry.name, + isEnabled: true, + version: method.version ?? "" + ) } /// Attempts downloading from `url`, with error handling and a retry policy diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift similarity index 55% rename from CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift rename to CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index efe5bc9e6..7ffe5100a 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -1,5 +1,5 @@ // -// ExtensionsSettingsRowView.swift +// LanguageServerRowView.swift // CodeEdit // // Created by Abe Malla on 2/2/25. @@ -7,8 +7,8 @@ import SwiftUI -struct ExtensionsSettingsRowView: View, Equatable { - let title: String +struct LanguageServerRowView: View, Equatable { + let packageName: String let subtitle: String let icon: String let onCancel: (() -> Void) @@ -24,19 +24,23 @@ struct ExtensionsSettingsRowView: View, Equatable { @State private var installProgress: Double = 0.0 init( - title: String, + packageName: String, subtitle: String, icon: String, + isInstalled: Bool = false, + isEnabled: Bool = false, onCancel: @escaping (() -> Void), onInstall: @escaping () async -> Void ) { - self.title = title + self.packageName = packageName self.subtitle = subtitle self.icon = icon + self.isInstalled = isInstalled + self.isEnabled = isEnabled self.onCancel = onCancel self.onInstall = onInstall - self.cleanedTitle = title + self.cleanedTitle = packageName .replacingOccurrences(of: "-", with: " ") .replacingOccurrences(of: "_", with: " ") .split(separator: " ") @@ -46,8 +50,7 @@ struct ExtensionsSettingsRowView: View, Equatable { if str == "ls" || str == "lsp" || str == "ci" || str == "cli" { return str.uppercased() } - // Normal capitalization for other words - return str.prefix(1).uppercased() + str.dropFirst() + return str.capitalized } .joined(separator: " ") self.cleanedSubtitle = subtitle.replacingOccurrences(of: "\n", with: " ") @@ -88,57 +91,75 @@ struct ExtensionsSettingsRowView: View, Equatable { @ViewBuilder private func installationButton() -> some View { if isInstalled { - HStack { - if isHovering { - Button { - isInstalling = false - isInstalled = false - } label: { - Text("Remove") - } - } - Toggle("", isOn: $isEnabled) - .toggleStyle(.switch) - .controlSize(.small) - .labelsHidden() - } + installedRow() } else if isInstalling { - ZStack { - CECircularProgressView(progress: installProgress) - .frame(width: 20, height: 20) + isInstallingRow() + } else if isHovering { + isHoveringRow() + } + } + + @ViewBuilder + private func installedRow() -> some View { + HStack { + if isHovering { Button { isInstalling = false - onCancel() + isInstalled = false } label: { - Image(systemName: "stop.fill") - .font(.system(size: 8)) - .foregroundColor(.blue) + Text("Remove") } - .buttonStyle(.plain) - .contentShape(Rectangle()) } - } else if isHovering { - Button { - isInstalling = true - withAnimation(.linear(duration: 3)) { - installProgress = 0.75 - } - Task { - await onInstall() - withAnimation(.linear(duration: 1)) { - installProgress = 1.0 - } - isInstalling = false - isInstalled = true - isEnabled = true + Toggle("", isOn: $isEnabled) + .onChange(of: isEnabled) { newValue in + RegistryManager.shared.installedLanguageServers[packageName]?.isEnabled = newValue } + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + } + + @ViewBuilder + private func isInstallingRow() -> some View { + ZStack { + CECircularProgressView(progress: installProgress) + .frame(width: 20, height: 20) + Button { + isInstalling = false + onCancel() } label: { - Text("Install") + Image(systemName: "stop.fill") + .font(.system(size: 8)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private func isHoveringRow() -> some View { + Button { + isInstalling = true + withAnimation(.linear(duration: 3)) { + installProgress = 0.75 + } + Task { + await onInstall() + withAnimation(.linear(duration: 1)) { + installProgress = 1.0 + } + isInstalling = false + isInstalled = true + isEnabled = true } + } label: { + Text("Install") } } - static func == (lhs: ExtensionsSettingsRowView, rhs: ExtensionsSettingsRowView) -> Bool { - lhs.title == rhs.title && lhs.subtitle == rhs.subtitle + static func == (lhs: LanguageServerRowView, rhs: LanguageServerRowView) -> Bool { + lhs.packageName == rhs.packageName && lhs.subtitle == rhs.subtitle } } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift similarity index 81% rename from CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift rename to CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 66e0202e3..50ebe54a6 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/ExtensionsSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct ExtensionsSettingsView: View { +struct LanguageServersView: View { @State private var didError = false @State private var installationFailure: InstallationFailure? @State private var registryItems: [RegistryItem] = [] @@ -25,14 +25,16 @@ struct ExtensionsSettingsView: View { } else { Section { List(registryItems, id: \.name) { item in - ExtensionsSettingsRowView( - title: item.name, + LanguageServerRowView( + packageName: item.name, subtitle: item.description, icon: "GitHubIcon", + isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, + isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, onCancel: { }, onInstall: { do { - try await RegistryManager.installPackage(package: item) + try await RegistryManager.shared.installPackage(package: item) } catch { installationFailure = InstallationFailure(error: error.localizedDescription) } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift index 57df91118..febec7155 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift @@ -24,7 +24,27 @@ extension SettingsData { .map { NSLocalizedString($0, comment: "") } } + /// Stores the currently installed language servers. The key is the name of the language server. + var installedLanguageServers: [String: InstalledLanguageServer] = [:] + /// Default initializer - init() {} + init() { + self.installedLanguageServers = [:] + } + + /// Explicit decoder init for setting default values when key is not present in `JSON` + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.installedLanguageServers = try container.decodeIfPresent( + [String: InstalledLanguageServer].self, + forKey: .installedLanguageServers + ) ?? [:] + } + } + + struct InstalledLanguageServer: Codable, Hashable { + let packageName: String + var isEnabled: Bool + let version: String } } diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 4265f1b44..460b62cf0 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -185,7 +185,7 @@ struct SettingsView: View { case .location: LocationsSettingsView() case .extensions: - ExtensionsSettingsView() + LanguageServersView() case .developer: DeveloperSettingsView() default: From 2f2c0223fab095ccf8f6fe43844f399b61250e4f Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 13 Mar 2025 18:39:24 -0700 Subject: [PATCH 10/38] Refactors, added text icon colors --- .../LSP/Registry/RegistryManager.swift | 8 ++-- .../Settings/Models/SettingsData.swift | 12 +++--- .../Settings/Models/SettingsPage.swift | 2 +- .../Extensions/LanguageServerRowView.swift | 40 ++++++++++++++----- .../Extensions/LanguageServersView.swift | 1 - ...ngs.swift => LanguageServerSettings.swift} | 7 ++-- CodeEdit/Features/Settings/SettingsView.swift | 4 +- 7 files changed, 45 insertions(+), 29 deletions(-) rename CodeEdit/Features/Settings/Pages/Extensions/Models/{ExtensionSettings.swift => LanguageServerSettings.swift} (88%) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 4874934b8..fee38b226 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -9,8 +9,8 @@ import Combine import Foundation import ZIPFoundation -let homeDirectory = FileManager.default.homeDirectoryForCurrentUser -let installPath = homeDirectory +private let homeDirectory = FileManager.default.homeDirectoryForCurrentUser +private let installPath = homeDirectory .appending(path: "Library") .appending(path: "Application Support") .appending(path: "CodeEdit") @@ -56,7 +56,7 @@ final class RegistryManager { return [] } - @AppSettings(\.extensions.installedLanguageServers) + @AppSettings(\.languageServers.installedLanguageServers) var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] deinit { @@ -169,7 +169,7 @@ final class RegistryManager { return true } let hoursSinceLastUpdate = Date().timeIntervalSince(modificationDate) / 3600 - return hoursSinceLastUpdate > 24 + return hoursSinceLastUpdate >= 24 }() if needsUpdate { diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index 3435e6fea..d726ecc44 100644 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ b/CodeEdit/Features/Settings/Models/SettingsData.swift @@ -50,8 +50,8 @@ struct SettingsData: Codable, Hashable { /// Search Settings var search: SearchSettings = .init() - /// Extension Settings - var extensions: ExtensionSettings = .init() + /// Language Server Settings + var languageServers: LanguageServerSettings = .init() /// Developer settings for CodeEdit developers var developerSettings: DeveloperSettings = .init() @@ -77,8 +77,8 @@ struct SettingsData: Codable, Hashable { KeybindingsSettings.self, forKey: .keybindings ) ?? .init() - self.extensions = try container.decodeIfPresent( - ExtensionSettings.self, forKey: .extensions + self.languageServers = try container.decodeIfPresent( + LanguageServerSettings.self, forKey: .languageServers ) ?? .init() self.developerSettings = try container.decodeIfPresent( DeveloperSettings.self, forKey: .developerSettings @@ -108,8 +108,8 @@ struct SettingsData: Codable, Hashable { sourceControl.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .location: LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } - case .extensions: - ExtensionSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .languageServers: + LanguageServerSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .developer: developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .behavior: return [.init(name, settingName: "Error")] diff --git a/CodeEdit/Features/Settings/Models/SettingsPage.swift b/CodeEdit/Features/Settings/Models/SettingsPage.swift index 08d800980..597532b55 100644 --- a/CodeEdit/Features/Settings/Models/SettingsPage.swift +++ b/CodeEdit/Features/Settings/Models/SettingsPage.swift @@ -32,7 +32,7 @@ struct SettingsPage: Hashable, Equatable, Identifiable { case components = "Components" case location = "Locations" case advanced = "Advanced" - case extensions = "Language Servers" // TODO: CHANGE NAME TO "Extensions" WHEN EXTENSIONS ARE IMPLEMENTED + case languageServers = "Language Servers" case developer = "Developer" } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index 7ffe5100a..ea892f34f 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -7,10 +7,11 @@ import SwiftUI +private let iconSize: CGFloat = 26 + struct LanguageServerRowView: View, Equatable { let packageName: String let subtitle: String - let icon: String let onCancel: (() -> Void) let onInstall: (() async -> Void) @@ -26,7 +27,6 @@ struct LanguageServerRowView: View, Equatable { init( packageName: String, subtitle: String, - icon: String, isInstalled: Bool = false, isEnabled: Bool = false, onCancel: @escaping (() -> Void), @@ -34,7 +34,6 @@ struct LanguageServerRowView: View, Equatable { ) { self.packageName = packageName self.subtitle = subtitle - self.icon = icon self.isInstalled = isInstalled self.isEnabled = isEnabled self.onCancel = onCancel @@ -68,14 +67,7 @@ struct LanguageServerRowView: View, Equatable { .truncationMode(.tail) } } icon: { - Image(icon) - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .frame(width: 26, height: 26) - .padding(.top, 2) - .padding(.bottom, 2) - .padding(.leading, 2) + letterIcon() } .opacity(isInstalled && !isEnabled ? 0.5 : 1.0) @@ -159,6 +151,32 @@ struct LanguageServerRowView: View, Equatable { } } + @ViewBuilder + private func letterIcon() -> some View { + RoundedRectangle(cornerRadius: iconSize / 4, style: .continuous) + .fill(background) + .overlay { + Text(String(cleanedTitle.first ?? Character(""))) + .font(.system(size: iconSize * 0.65)) + .foregroundColor(.primary) + } + .clipShape(RoundedRectangle(cornerRadius: iconSize / 4, style: .continuous)) + .shadow( + color: Color(NSColor.black).opacity(0.25), + radius: iconSize / 40, + y: iconSize / 40 + ) + .frame(width: iconSize, height: iconSize) + } + + private var background: AnyShapeStyle { + let colors: [Color] = [ + .blue, .green, .orange, .red, .purple, .pink, .teal, .yellow, .indigo, .cyan + ] + let hashValue = abs(cleanedTitle.hashValue) % colors.count + return AnyShapeStyle(colors[hashValue].gradient) + } + static func == (lhs: LanguageServerRowView, rhs: LanguageServerRowView) -> Bool { lhs.packageName == rhs.packageName && lhs.subtitle == rhs.subtitle } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 50ebe54a6..3651f099b 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -28,7 +28,6 @@ struct LanguageServersView: View { LanguageServerRowView( packageName: item.name, subtitle: item.description, - icon: "GitHubIcon", isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, onCancel: { }, diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift similarity index 88% rename from CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift rename to CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift index febec7155..9e65691a9 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/Models/ExtensionSettings.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift @@ -1,5 +1,5 @@ // -// ExtensionSettings.swift +// LanguageServerSettings.swift // CodeEdit // // Created by Abe Malla on 2/2/25. @@ -8,13 +8,12 @@ import Foundation extension SettingsData { - struct ExtensionSettings: Codable, Hashable, SearchableSettingsPage { + struct LanguageServerSettings: Codable, Hashable, SearchableSettingsPage { /// The search keys var searchKeys: [String] { [ - "Extensions", - "Language Server", + "Language Servers", "LSP Binaries", "Linters", "Formatters", diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 460b62cf0..1113bbf9d 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -87,7 +87,7 @@ struct SettingsView: View { ), .init( SettingsPage( - .extensions, + .languageServers, baseColor: Color(hex: "#6A69DC"), // Purple icon: .system("cube.box.fill") ) @@ -184,7 +184,7 @@ struct SettingsView: View { SourceControlSettingsView() case .location: LocationsSettingsView() - case .extensions: + case .languageServers: LanguageServersView() case .developer: DeveloperSettingsView() From ed9dfe6c9cda114a8dae0d85545a2bd5eade31be Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 13 Mar 2025 19:21:45 -0700 Subject: [PATCH 11/38] Update notifications --- .../LSP/Registry/RegistryManager.swift | 74 ++++++++++++++++++- .../Settings/Models/SettingsData.swift | 4 +- .../Extensions/LanguageServerRowView.swift | 10 +-- .../Extensions/LanguageServersView.swift | 1 + 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index fee38b226..f5c2a1702 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -14,7 +14,7 @@ private let installPath = homeDirectory .appending(path: "Library") .appending(path: "Application Support") .appending(path: "CodeEdit") - .appending(path: "extensions") + .appending(path: "language-servers") final class RegistryManager { static let shared: RegistryManager = .init() @@ -122,13 +122,34 @@ final class RegistryManager { guard let manager = Self.createPackageManager(for: method) else { throw PackageManagerError.invalidConfiguration } - try await manager.install(method: method) + // Add to activity viewer + let activityTitle = "\(entry.name)\("@" + (method.version ?? "latest"))" + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": entry.name, + "action": "create", + "title": "Installing \(activityTitle)" + ] + ) + + do { + try await manager.install(method: method) + } catch { + Self.updateActivityViewer(entry.name, activityTitle, fail: true) + // Throw error again so the UI can catch it + throw error + } + + // Save to settings installedLanguageServers[entry.name] = .init( packageName: entry.name, isEnabled: true, version: method.version ?? "" ) + Self.updateActivityViewer(entry.name, activityTitle, fail: false) } /// Attempts downloading from `url`, with error handling and a retry policy @@ -227,6 +248,55 @@ final class RegistryManager { return nil } } + + /// Updates the activity viewer with the status of the language server installation + private static func updateActivityViewer( + _ id: String, + _ activityName: String, + fail failed: Bool + ) { + if failed { + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "update", + "title": "Could not install \(activityName)", + "isLoading": false + ] + ) + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "deleteWithDelay", + "delay": 5.0, + ] + ) + } else { + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "update", + "title": "Successfully installed \(activityName)", + "isLoading": false + ] + ) + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "deleteWithDelay", + "delay": 5.0, + ] + ) + } + } } /// `CachedRegistry` is a timer based cache that will remove the registry items from memory diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index d726ecc44..cd860c7e4 100644 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ b/CodeEdit/Features/Settings/Models/SettingsData.swift @@ -109,7 +109,9 @@ struct SettingsData: Codable, Hashable { case .location: LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .languageServers: - LanguageServerSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + LanguageServerSettings().searchKeys.forEach { + settings.append(.init(name, isSetting: true, settingName: $0)) + } case .developer: developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .behavior: return [.init(name, settingName: "Error")] diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index ea892f34f..0c671bd89 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -22,7 +22,6 @@ struct LanguageServerRowView: View, Equatable { @State private var isInstalling: Bool = false @State private var isInstalled: Bool = false @State private var isEnabled = false - @State private var installProgress: Double = 0.0 init( packageName: String, @@ -115,7 +114,7 @@ struct LanguageServerRowView: View, Equatable { @ViewBuilder private func isInstallingRow() -> some View { ZStack { - CECircularProgressView(progress: installProgress) + CECircularProgressView() .frame(width: 20, height: 20) Button { isInstalling = false @@ -134,14 +133,9 @@ struct LanguageServerRowView: View, Equatable { private func isHoveringRow() -> some View { Button { isInstalling = true - withAnimation(.linear(duration: 3)) { - installProgress = 0.75 - } + Task { await onInstall() - withAnimation(.linear(duration: 1)) { - installProgress = 1.0 - } isInstalling = false isInstalled = true isEnabled = true diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 3651f099b..470f36f43 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -35,6 +35,7 @@ struct LanguageServersView: View { do { try await RegistryManager.shared.installPackage(package: item) } catch { + didError = true installationFailure = InstallationFailure(error: error.localizedDescription) } } From 64ceb745a8ed13cfefb528a8ca5da3dc180e746e Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 13 Mar 2025 23:42:56 -0700 Subject: [PATCH 12/38] Add more documentation --- .../LSP/Registry/RegistryManager.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index f5c2a1702..a0badad65 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -19,14 +19,18 @@ private let installPath = homeDirectory final class RegistryManager { static let shared: RegistryManager = .init() - private let saveLocation = installPath + /// The URL of where the registry.json file will be downloaded from private let registryURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" )! + /// The URL of where the checksums.txt file will be downloaded from private let checksumURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! - private var cancellables: Set = [] + /// A queue for installing packages concurrently + private let installQueue: OperationQueue + /// The max amount of package concurrent installs + private let maxConcurrentInstallations: Int = 2 /// Rreference to cached registry data. Will be removed from memory after a certain amount of time. private var cachedRegistry: CachedRegistry? @@ -59,6 +63,11 @@ final class RegistryManager { @AppSettings(\.languageServers.installedLanguageServers) var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + private init() { + installQueue = OperationQueue() + installQueue.maxConcurrentOperationCount = maxConcurrentInstallations + } + deinit { cleanupTimer?.invalidate() } @@ -70,26 +79,26 @@ final class RegistryManager { do { // Make sure the extensions folder exists first - try FileManager.default.createDirectory(at: saveLocation, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: installPath, withIntermediateDirectories: true) let (registryData, checksumData) = try await (zipDataTask, checksumsTask) - let tempZipURL = saveLocation.appending(path: "temp.zip") - let checksumDestination = saveLocation.appending(path: "checksums.txt") + let tempZipURL = installPath.appending(path: "temp.zip") + let checksumDestination = installPath.appending(path: "checksums.txt") do { // Delete existing zip data if it exists if FileManager.default.fileExists(atPath: tempZipURL.path) { try FileManager.default.removeItem(at: tempZipURL) } - let registryJsonPath = saveLocation.appending(path: "registry.json").path + let registryJsonPath = installPath.appending(path: "registry.json").path if FileManager.default.fileExists(atPath: registryJsonPath) { try FileManager.default.removeItem(atPath: registryJsonPath) } // Write the zip data to a temporary file, then unzip try registryData.write(to: tempZipURL) - try FileManager.default.unzipItem(at: tempZipURL, to: saveLocation) + try FileManager.default.unzipItem(at: tempZipURL, to: installPath) try FileManager.default.removeItem(at: tempZipURL) try checksumData.write(to: checksumDestination) @@ -180,7 +189,7 @@ final class RegistryManager { /// Loads registry items from disk private func loadItemsFromDisk() -> [RegistryItem]? { - let registryPath = saveLocation.appending(path: "registry.json") + let registryPath = installPath.appending(path: "registry.json") let fileManager = FileManager.default // Update the file every 24 hours From 04fde2c87ab71b836ec2435df9c3eb461e20cba7 Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 14 Mar 2025 00:25:45 -0700 Subject: [PATCH 13/38] Added installation queuing, fix runtime warnings --- .../Registry/InstallationQueueManager.swift | 142 ++++++++++++++++++ .../LSP/Registry/RegistryManager.swift | 21 +-- .../Extensions/LanguageServerRowView.swift | 84 ++++++++--- .../Extensions/LanguageServersView.swift | 18 ++- 4 files changed, 222 insertions(+), 43 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift diff --git a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift new file mode 100644 index 000000000..9ae3a4c6c --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift @@ -0,0 +1,142 @@ +// +// InstallationQueueManager.swift +// CodeEdit +// +// Created by Abe Malla on 3/13/25. +// + +import Foundation + +/// A class to manage queued installations of language servers +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: Int = 0 + /// Installation status dictionary + private var installationStatus: [String: PackageInstallationStatus] = [:] + + private init() {} + + /// Add a package to the installation queue + func queueInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { + installationStatus[package.name] = .queued + installationQueue.append((package, completion)) + processNextInstallations() + + // Notify UI that package is queued + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.queued] + ) + } + } + + /// Process next installations from the queue if possible + private func processNextInstallations() { + while runningInstallations < maxConcurrentInstallations && !installationQueue.isEmpty { + let (package, completion) = installationQueue.removeFirst() + runningInstallations += 1 + installationStatus[package.name] = .installing + + // 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 -= 1 + processNextInstallations() + } + } + } + + /// Cancel an installation if it's in the queue + func cancelInstallation(packageName: String) { + installationQueue.removeAll { $0.0.name == packageName } + installationStatus[packageName] = .cancelled + + // Notify UI that installation was cancelled + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": packageName, "status": PackageInstallationStatus.cancelled] + ) + } + } + + /// Get the current status of an installation + func getInstallationStatus(packageName: String) -> PackageInstallationStatus { + return installationStatus[packageName] ?? .notQueued + } +} + +/// 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/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index a0badad65..34c7e9187 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -27,10 +27,6 @@ final class RegistryManager { private let checksumURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! - /// A queue for installing packages concurrently - private let installQueue: OperationQueue - /// The max amount of package concurrent installs - private let maxConcurrentInstallations: Int = 2 /// Rreference to cached registry data. Will be removed from memory after a certain amount of time. private var cachedRegistry: CachedRegistry? @@ -63,11 +59,6 @@ final class RegistryManager { @AppSettings(\.languageServers.installedLanguageServers) var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] - private init() { - installQueue = OperationQueue() - installQueue.maxConcurrentOperationCount = maxConcurrentInstallations - } - deinit { cleanupTimer?.invalidate() } @@ -153,11 +144,13 @@ final class RegistryManager { } // Save to settings - installedLanguageServers[entry.name] = .init( - packageName: entry.name, - isEnabled: true, - version: method.version ?? "" - ) + DispatchQueue.main.async { [weak self] in + self?.installedLanguageServers[entry.name] = .init( + packageName: entry.name, + isEnabled: true, + version: method.version ?? "" + ) + } Self.updateActivityViewer(entry.name, activityTitle, fail: false) } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index 0c671bd89..fc488d7f9 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -19,7 +19,7 @@ struct LanguageServerRowView: View, Equatable { private let cleanedSubtitle: String @State private var isHovering: Bool = false - @State private var isInstalling: Bool = false + @State private var installationStatus: PackageInstallationStatus = .notQueued @State private var isInstalled: Bool = false @State private var isEnabled = false @@ -77,16 +77,38 @@ 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 + } + } + } } @ViewBuilder private func installationButton() -> some View { if isInstalled { installedRow() - } else if isInstalling { - isInstallingRow() - } else if isHovering { - isHoveringRow() + } else { + switch installationStatus { + case .installing, .queued: + isInstallingRow() + case .failed: + failedRow() + default: + if isHovering { + isHoveringRow() + } + } } } @@ -95,7 +117,6 @@ struct LanguageServerRowView: View, Equatable { HStack { if isHovering { Button { - isInstalling = false isInstalled = false } label: { Text("Remove") @@ -113,32 +134,49 @@ struct LanguageServerRowView: View, Equatable { @ViewBuilder private func isInstallingRow() -> some View { - ZStack { - CECircularProgressView() - .frame(width: 20, height: 20) - Button { - isInstalling = false - onCancel() - } label: { - Image(systemName: "stop.fill") - .font(.system(size: 8)) - .foregroundColor(.blue) + 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") + .font(.system(size: 8)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) } - .buttonStyle(.plain) - .contentShape(Rectangle()) } } @ViewBuilder - private func isHoveringRow() -> some View { + private func failedRow() -> some View { Button { - isInstalling = true + // Reset status and retry installation + installationStatus = .notQueued + Task { + await onInstall() + } + } label: { + Text("Retry") + .foregroundColor(.red) + } + } + @ViewBuilder + private func isHoveringRow() -> some View { + Button { Task { await onInstall() - isInstalling = false - isInstalled = true - isEnabled = true } } label: { Text("Install") diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 470f36f43..c7b7c0046 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -30,13 +30,19 @@ struct LanguageServersView: View { subtitle: item.description, isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, - onCancel: { }, + onCancel: { + InstallationQueueManager.shared.cancelInstallation(packageName: item.name) + }, onInstall: { - do { - try await RegistryManager.shared.installPackage(package: item) - } catch { - didError = true - installationFailure = InstallationFailure(error: error.localizedDescription) + 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) + } } } ) From 6b40bb35275c554204ec3619928b3e1e4920d427 Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 14 Mar 2025 01:46:36 -0700 Subject: [PATCH 14/38] Convert to actor --- .../Registry/RegistryManager+Parsing.swift | 54 +++++ .../LSP/Registry/RegistryManager.swift | 195 +++++++++--------- 2 files changed, 146 insertions(+), 103 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift new file mode 100644 index 000000000..a2ad22d6a --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift @@ -0,0 +1,54 @@ +// +// 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 + } + } + + /// Create the appropriate package manager for the given installation method + internal static func createPackageManager( + for method: InstallationMethod, + _ installPath: URL + ) -> 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 + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 34c7e9187..f58fba1b7 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -16,6 +16,7 @@ private let installPath = homeDirectory .appending(path: "CodeEdit") .appending(path: "language-servers") +@MainActor final class RegistryManager { static let shared: RegistryManager = .init() @@ -47,8 +48,11 @@ final class RegistryManager { cleanupTimer = Timer.scheduledTimer( withTimeInterval: CachedRegistry.expirationInterval, repeats: false ) { [weak self] _ in - self?.cachedRegistry = nil - self?.cleanupTimer = nil + Task { @MainActor in + guard let self = self else { return } + self.cachedRegistry = nil + self.cleanupTimer = nil + } } return items } @@ -65,44 +69,63 @@ final class RegistryManager { /// Downloads the latest registry and saves to "~/Library/Application Support/CodeEdit/extensions" func update() async { - async let zipDataTask = download(from: registryURL) - async let checksumsTask = download(from: checksumURL) + // 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 + + if let error = result.error { + handleUpdateError(error) + return + } + + guard let registryData = result.registryData, let checksumData = result.checksumData else { + return + } do { // Make sure the extensions folder exists first try FileManager.default.createDirectory(at: installPath, withIntermediateDirectories: true) - let (registryData, checksumData) = try await (zipDataTask, checksumsTask) - let tempZipURL = installPath.appending(path: "temp.zip") let checksumDestination = installPath.appending(path: "checksums.txt") - do { - // Delete existing zip data if it exists - if FileManager.default.fileExists(atPath: tempZipURL.path) { - try FileManager.default.removeItem(at: tempZipURL) - } - let registryJsonPath = installPath.appending(path: "registry.json").path - if FileManager.default.fileExists(atPath: registryJsonPath) { - try FileManager.default.removeItem(atPath: registryJsonPath) - } - - // Write the zip data to a temporary file, then unzip - try registryData.write(to: tempZipURL) - try FileManager.default.unzipItem(at: tempZipURL, to: installPath) + // Delete existing zip data if it exists + if FileManager.default.fileExists(atPath: tempZipURL.path) { try FileManager.default.removeItem(at: tempZipURL) + } + let registryJsonPath = installPath.appending(path: "registry.json").path + if FileManager.default.fileExists(atPath: registryJsonPath) { + try FileManager.default.removeItem(atPath: registryJsonPath) + } - try checksumData.write(to: checksumDestination) + // Write the zip data to a temporary file, then unzip + try registryData.write(to: tempZipURL) + try FileManager.default.unzipItem(at: tempZipURL, to: installPath) + try FileManager.default.removeItem(at: tempZipURL) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) - } - } catch { - print("Error details: \(error)") - throw RegistryManagerError.writeFailed(error: error) - } - } catch let error as RegistryManagerError { - switch error { + try checksumData.write(to: checksumDestination) + + NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) + } catch { + print("Error details: \(error)") + handleUpdateError(RegistryManagerError.writeFailed(error: error)) + } + } + + private func handleUpdateError(_ error: Error) { + if let regError = error as? RegistryManagerError { + switch regError { case .invalidResponse(let statusCode): print("Invalid response received: \(statusCode)") case let .downloadFailed(url, error): @@ -112,50 +135,56 @@ final class RegistryManager { case let .writeFailed(error): print("Failed to write files to disk: \(error.localizedDescription)") } - } catch { + } else { print("Unexpected registry error: \(error.localizedDescription)") } } func installPackage(package entry: RegistryItem) async throws { - let method = Self.parseRegistryEntry(entry) - guard let manager = Self.createPackageManager(for: method) else { - throw PackageManagerError.invalidConfiguration - } + return try await Task.detached(priority: .userInitiated) { () in + let method = await Self.parseRegistryEntry(entry) + guard let manager = await Self.createPackageManager(for: method, installPath) else { + throw PackageManagerError.invalidConfiguration + } - // Add to activity viewer - let activityTitle = "\(entry.name)\("@" + (method.version ?? "latest"))" - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": entry.name, - "action": "create", - "title": "Installing \(activityTitle)" - ] - ) + // 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 { - Self.updateActivityViewer(entry.name, activityTitle, fail: true) - // Throw error again so the UI can catch it - throw error - } + 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 + } - // Save to settings - DispatchQueue.main.async { [weak self] in - self?.installedLanguageServers[entry.name] = .init( - packageName: entry.name, - isEnabled: true, - version: method.version ?? "" - ) - } - Self.updateActivityViewer(entry.name, activityTitle, fail: false) + // 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 } /// Attempts downloading from `url`, with error handling and a retry policy - private func download(from url: URL, attempt: Int = 1) async throws -> Data { + private static func download(from url: URL, attempt: Int = 1) async throws -> Data { do { let (data, response) = try await URLSession.shared.data(from: url) @@ -210,48 +239,8 @@ final class RegistryManager { } } - /// Parse a registry entry and create the appropriate installation method - private 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 - } - } - - /// Create the appropriate package manager for the given installation method - private static 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 - } - } - /// Updates the activity viewer with the status of the language server installation + @MainActor private static func updateActivityViewer( _ id: String, _ activityName: String, From 0fe73efed42063b1a13dc9943b2538d870423afe Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 14 Mar 2025 03:54:38 -0700 Subject: [PATCH 15/38] Refactors, fix and update removals --- .../LSP/Registry/PackageManagerProtocol.swift | 12 +- .../PackageManagers/CargoPackageManager.swift | 8 +- .../GithubPackageManager.swift | 8 +- .../GolangPackageManager.swift | 18 +- .../PackageManagers/NPMPackageManager.swift | 14 +- .../PackageManagers/PipPackageManager.swift | 12 +- .../PackageSourceParser+Cargo.swift | 3 +- .../PackageSourceParser+Gem.swift | 3 +- .../PackageSourceParser+Golang.swift | 3 +- .../PackageSourceParser+NPM.swift | 7 +- .../PackageSourceParser+PYPI.swift | 3 +- .../PackageSourceParser.swift | 3 +- .../RegistryManager+HandleRegistryFile.swift | 139 +++++++++++++ .../Registry/RegistryManager+Parsing.swift | 24 --- .../LSP/Registry/RegistryManager.swift | 183 +++++------------- .../Extensions/LanguageServerRowView.swift | 44 ++++- .../Extensions/LanguageServersView.swift | 2 + 17 files changed, 288 insertions(+), 198 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift index 95a948c4f..850e62f2a 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -95,7 +95,9 @@ struct PackageSource: Equatable, Codable { /// The type of the package manager let type: PackageManagerType /// Package name - let name: String + 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 @@ -108,7 +110,8 @@ struct PackageSource: Equatable, Codable { init( sourceId: String, type: PackageManagerType, - name: String, + pkgName: String, + entryName: String, version: String, repositoryUrl: String? = nil, gitReference: GitReference? = nil, @@ -116,7 +119,8 @@ struct PackageSource: Equatable, Codable { ) { self.sourceId = sourceId self.type = type - self.name = name + self.pkgName = pkgName + self.entryName = entryName self.version = version self.repositoryUrl = repositoryUrl self.gitReference = gitReference @@ -140,7 +144,7 @@ enum InstallationMethod: Equatable { case .standardPackage(let source), .sourceBuild(let source, _), .binaryDownload(let source, _): - return source.name + return source.pkgName case .unknown: return nil } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index 7df756001..ad4dd2004 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -34,8 +34,8 @@ class CargoPackageManager: PackageManagerProtocol { throw PackageManagerError.invalidConfiguration } - let packagePath = installationDirectory.appending(path: source.name) - print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + let packagePath = installationDirectory.appending(path: source.entryName) + print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) @@ -52,7 +52,7 @@ class CargoPackageManager: PackageManagerProtocol { cargoArgs.append(contentsOf: ["--rev", rev]) } } else { - cargoArgs.append("\(source.name)@\(source.version)") + cargoArgs.append("\(source.pkgName)@\(source.version)") } if let features = source.options["features"] { @@ -63,7 +63,7 @@ class CargoPackageManager: PackageManagerProtocol { } _ = try await executeInDirectory(in: packagePath.path, cargoArgs) - print("Successfully installed \(source.name)@\(source.version)") + print("Successfully installed \(source.entryName)@\(source.version)") } catch { print("Installation failed: \(error)") throw error diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index 57b82b103..c5ca96921 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -34,7 +34,7 @@ class GithubPackageManager: PackageManagerProtocol { throw PackageManagerError.invalidConfiguration } - let packagePath = installationDirectory.appending(path: source.name) + let packagePath = installationDirectory.appending(path: source.entryName) try await initialize(in: packagePath) switch method { @@ -67,7 +67,7 @@ class GithubPackageManager: PackageManagerProtocol { private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { _ = try await URLSession.shared.data(from: url) let fileName = url.lastPathComponent - let downloadPath = installationDirectory.appending(path: source.name) + let downloadPath = installationDirectory.appending(path: source.entryName) let packagePath = downloadPath.appending(path: fileName) if !FileManager.default.fileExists(atPath: packagePath.path) { @@ -83,10 +83,10 @@ class GithubPackageManager: PackageManagerProtocol { } private func installFromSource(_ source: PackageSource, _ command: String) async throws { - let installPath = installationDirectory.appending(path: source.name, directoryHint: .isDirectory) + let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) do { _ = try await executeInDirectory(in: installPath.path, ["git", "clone", source.repositoryUrl!]) - let repoPath = installPath.appending(path: source.name, directoryHint: .isDirectory) + let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) _ = try await executeInDirectory(in: repoPath.path, [command]) } catch { print("Failed to build from source: \(error)") diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index 0ec1aef99..21bf0f1b2 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -42,8 +42,8 @@ class GolangPackageManager: PackageManagerProtocol { throw PackageManagerError.invalidConfiguration } - let packagePath = installationDirectory.appending(path: source.name) - print("Installing Go package \(source.name)@\(source.version) in \(packagePath.path)") + let packagePath = installationDirectory.appending(path: source.entryName) + print("Installing Go package \(source.entryName)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) @@ -62,13 +62,13 @@ class GolangPackageManager: PackageManagerProtocol { } let binaryName = subpath.components(separatedBy: "/").last ?? - source.name.components(separatedBy: "/").last ?? source.name + source.pkgName.components(separatedBy: "/").last ?? source.pkgName let buildArgs = ["go", "build", "-o", "bin/\(binaryName)"] - // If source.name includes the full import path (like github.com/owner/repo) - if source.name.contains("/") { + // If source.pkgName includes the full import path (like github.com/owner/repo) + if source.pkgName.contains("/") { _ = try await executeInDirectory( - in: packagePath.path, buildArgs + ["\(source.name)/\(subpath)"] + in: packagePath.path, buildArgs + ["\(source.pkgName)/\(subpath)"] ) } else { _ = try await executeInDirectory( @@ -79,7 +79,7 @@ class GolangPackageManager: PackageManagerProtocol { _ = try await runCommand("chmod +x \"\(execPath)\"") } - print("Successfully installed \(source.name)@\(source.version)") + print("Successfully installed \(source.entryName)@\(source.version)") } catch { print("Installation failed: \(error)") try? cleanupFailedInstallation(packagePath: packagePath) @@ -138,7 +138,7 @@ class GolangPackageManager: PackageManagerProtocol { private func getGoInstallCommand(_ source: PackageSource) -> String { if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { // Check if this is a Git-based package - var packageName = source.name + var packageName = source.pkgName if !packageName.contains("github.com") && !packageName.contains("golang.org") { packageName = repoUrl.replacingOccurrences(of: "https://", with: "") } @@ -153,7 +153,7 @@ class GolangPackageManager: PackageManagerProtocol { return "\(packageName)@\(gitVersion)" } else { - return "\(source.name)@\(source.version)" + return "\(source.pkgName)@\(source.version)" } } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index 4e8fc7533..f5e1114cb 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -55,13 +55,13 @@ class NPMPackageManager: PackageManagerProtocol { throw PackageManagerError.invalidConfiguration } - let packagePath = installationDirectory.appending(path: source.name) - print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + let packagePath = installationDirectory.appending(path: source.entryName) + print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) do { - var installArgs = ["npm", "install", "\(source.name)@\(source.version)"] + var installArgs = ["npm", "install", "\(source.pkgName)@\(source.version)"] if let dev = source.options["dev"], dev.lowercased() == "true" { installArgs.append("--save-dev") } @@ -72,9 +72,9 @@ class NPMPackageManager: PackageManagerProtocol { } _ = try await executeInDirectory(in: packagePath.path, installArgs) - try verifyInstallation(package: source.name, version: source.version) + try verifyInstallation(folderName: source.entryName, package: source.pkgName, version: source.version) - print("Successfully installed \(source.name)@\(source.version)") + print("Successfully installed \(source.entryName)@\(source.version)") } catch { print("Installation failed: \(error)") let nodeModulesPath = packagePath.appending(path: "node_modules").path @@ -107,8 +107,8 @@ class NPMPackageManager: PackageManagerProtocol { } /// Verify the installation was successful - private func verifyInstallation(package: String, version: String) throws { - let packagePath = installationDirectory.appending(path: package) + 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 diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index ba199572b..d25ecece8 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -42,8 +42,8 @@ class PipPackageManager: PackageManagerProtocol { throw PackageManagerError.invalidConfiguration } - let packagePath = installationDirectory.appending(path: source.name) - print("Installing \(source.name)@\(source.version) in \(packagePath.path)") + let packagePath = installationDirectory.appending(path: source.entryName) + print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") try await initialize(in: packagePath) @@ -52,9 +52,9 @@ class PipPackageManager: PackageManagerProtocol { var installArgs = [pipCommand, "install"] if source.version.lowercased() != "latest" { - installArgs.append("\(source.name)==\(source.version)") + installArgs.append("\(source.pkgName)==\(source.version)") } else { - installArgs.append(source.name) + installArgs.append(source.pkgName) } let extras = source.options["extra"] @@ -66,9 +66,9 @@ class PipPackageManager: PackageManagerProtocol { _ = try await executeInDirectory(in: packagePath.path, installArgs) try await updateRequirements(packagePath: packagePath) - try await verifyInstallation(packagePath: packagePath, package: source.name) + try await verifyInstallation(packagePath: packagePath, package: source.pkgName) - print("Successfully installed \(source.name)@\(source.version)") + print("Successfully installed \(source.entryName)@\(source.version)") } catch { print("Installation failed: \(error)") throw error diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift index e6b1efe70..7eebccbf2 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -61,7 +61,8 @@ extension PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: .cargo, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift index e8898a972..4f3711a57 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -51,7 +51,8 @@ extension PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: .gem, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift index 67abc7dfb..287afdcde 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -63,7 +63,8 @@ extension PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: .golang, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift index 46636bebf..646f9fa7a 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -19,7 +19,7 @@ extension PackageSourceParser { let packageVersion = String(components[0]) let parameters = components.count > 1 ? String(components[1]) : "" - let (packageName, version) = parseNPMPackageVersion(packageVersion) + let (packageName, version) = parseNPMPackageNameAndVersion(packageVersion) // Parse parameters as options var options: [String: String] = ["buildTool": "npm"] @@ -48,7 +48,8 @@ extension PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: .npm, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, @@ -57,7 +58,7 @@ extension PackageSourceParser { return .standardPackage(source: source) } - private static func parseNPMPackageVersion(_ packageVersion: String) -> (String, String) { + private static func parseNPMPackageNameAndVersion(_ packageVersion: String) -> (String, String) { var packageName: String var version: String = "latest" diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift index 2f9cd9a4c..c1b37f50e 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift @@ -51,7 +51,8 @@ extension PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: .pip, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift index 23ef6d19f..35d24dcf5 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -43,7 +43,8 @@ enum PackageSourceParser { let source = PackageSource( sourceId: sourceId, type: isSourceBuild ? .sourceBuild : .github, - name: packageName, + pkgName: packageName, + entryName: entry.name, version: version, repositoryUrl: repositoryUrl, gitReference: gitReference, diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift new file mode 100644 index 000000000..76d811797 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -0,0 +1,139 @@ +// +// RegistryManager+HandleRegistryFile.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +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 + + if let error = result.error { + handleUpdateError(error) + return + } + + guard let registryData = result.registryData, let checksumData = result.checksumData else { + return + } + + do { + // Make sure the extensions folder exists first + try FileManager.default.createDirectory(at: installPath, withIntermediateDirectories: true) + + let tempZipURL = installPath.appending(path: "temp.zip") + let checksumDestination = installPath.appending(path: "checksums.txt") + + // Delete existing zip data if it exists + if FileManager.default.fileExists(atPath: tempZipURL.path) { + try FileManager.default.removeItem(at: tempZipURL) + } + let registryJsonPath = installPath.appending(path: "registry.json").path + if FileManager.default.fileExists(atPath: registryJsonPath) { + try FileManager.default.removeItem(atPath: registryJsonPath) + } + + // Write the zip data to a temporary file, then unzip + try registryData.write(to: tempZipURL) + try FileManager.default.unzipItem(at: tempZipURL, to: installPath) + try FileManager.default.removeItem(at: tempZipURL) + + try checksumData.write(to: checksumDestination) + + NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) + } catch { + print("Error details: \(error)") + handleUpdateError(RegistryManagerError.writeFailed(error: error)) + } + } + + internal func handleUpdateError(_ error: Error) { + if let regError = error as? RegistryManagerError { + switch regError { + case .invalidResponse(let statusCode): + print("Invalid response received: \(statusCode)") + case let .downloadFailed(url, error): + print("Download failed for \(url.absoluteString): \(error.localizedDescription)") + case let .maxRetriesExceeded(url, error): + print("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") + case let .writeFailed(error): + print("Failed to write files to disk: \(error.localizedDescription)") + } + } else { + print("Unexpected registry error: \(error.localizedDescription)") + } + } + + /// Attempts downloading from `url`, with error handling and a retry policy + internal static func download(from url: URL, attempt: Int = 1) async throws -> Data { + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RegistryManagerError.downloadFailed( + url: url, error: NSError(domain: "Invalid response type", code: -1) + ) + } + guard (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.invalidResponse(statusCode: httpResponse.statusCode) + } + + return data + } catch { + if attempt <= 3 { + let delay = pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + return try await download(from: url, attempt: attempt + 1) + } else { + throw RegistryManagerError.maxRetriesExceeded(url: url, lastError: error) + } + } + } + + /// Loads registry items from disk + internal func loadItemsFromDisk() -> [RegistryItem]? { + let registryPath = installPath.appending(path: "registry.json") + let fileManager = FileManager.default + + // Update the file every 24 hours + let needsUpdate = !fileManager.fileExists(atPath: registryPath.path) || { + guard let attributes = try? fileManager.attributesOfItem(atPath: registryPath.path), + let modificationDate = attributes[.modificationDate] as? Date else { + return true + } + let hoursSinceLastUpdate = Date().timeIntervalSince(modificationDate) / 3600 + return hoursSinceLastUpdate >= 24 + }() + + if needsUpdate { + Task { await update() } + return nil + } + + do { + let registryData = try Data(contentsOf: registryPath) + 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 index a2ad22d6a..06bddba5a 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift @@ -27,28 +27,4 @@ extension RegistryManager { return .unknown } } - - /// Create the appropriate package manager for the given installation method - internal static func createPackageManager( - for method: InstallationMethod, - _ installPath: URL - ) -> 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 - } - } } diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index f58fba1b7..17e644247 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -9,23 +9,22 @@ import Combine import Foundation import ZIPFoundation -private let homeDirectory = FileManager.default.homeDirectoryForCurrentUser -private let installPath = homeDirectory - .appending(path: "Library") - .appending(path: "Application Support") - .appending(path: "CodeEdit") - .appending(path: "language-servers") - @MainActor final class RegistryManager { static let shared: RegistryManager = .init() + internal let installPath = FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library") + .appending(path: "Application Support") + .appending(path: "CodeEdit") + .appending(path: "language-servers") + /// The URL of where the registry.json file will be downloaded from - private let registryURL = URL( + internal let registryURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" )! /// The URL of where the checksums.txt file will be downloaded from - private let checksumURL = URL( + internal let checksumURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! @@ -67,83 +66,10 @@ final class RegistryManager { cleanupTimer?.invalidate() } - /// Downloads the latest registry and saves to "~/Library/Application Support/CodeEdit/extensions" - 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 - - if let error = result.error { - handleUpdateError(error) - return - } - - guard let registryData = result.registryData, let checksumData = result.checksumData else { - return - } - - do { - // Make sure the extensions folder exists first - try FileManager.default.createDirectory(at: installPath, withIntermediateDirectories: true) - - let tempZipURL = installPath.appending(path: "temp.zip") - let checksumDestination = installPath.appending(path: "checksums.txt") - - // Delete existing zip data if it exists - if FileManager.default.fileExists(atPath: tempZipURL.path) { - try FileManager.default.removeItem(at: tempZipURL) - } - let registryJsonPath = installPath.appending(path: "registry.json").path - if FileManager.default.fileExists(atPath: registryJsonPath) { - try FileManager.default.removeItem(atPath: registryJsonPath) - } - - // Write the zip data to a temporary file, then unzip - try registryData.write(to: tempZipURL) - try FileManager.default.unzipItem(at: tempZipURL, to: installPath) - try FileManager.default.removeItem(at: tempZipURL) - - try checksumData.write(to: checksumDestination) - - NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) - } catch { - print("Error details: \(error)") - handleUpdateError(RegistryManagerError.writeFailed(error: error)) - } - } - - private func handleUpdateError(_ error: Error) { - if let regError = error as? RegistryManagerError { - switch regError { - case .invalidResponse(let statusCode): - print("Invalid response received: \(statusCode)") - case let .downloadFailed(url, error): - print("Download failed for \(url.absoluteString): \(error.localizedDescription)") - case let .maxRetriesExceeded(url, error): - print("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") - case let .writeFailed(error): - print("Failed to write files to disk: \(error.localizedDescription)") - } - } else { - print("Unexpected registry error: \(error.localizedDescription)") - } - } - 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, installPath) else { + guard let manager = await self.createPackageManager(for: method) else { throw PackageManagerError.invalidConfiguration } @@ -183,59 +109,35 @@ final class RegistryManager { }.value } - /// Attempts downloading from `url`, with error handling and a retry policy - private static func download(from url: URL, attempt: Int = 1) async throws -> Data { - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse else { - throw RegistryManagerError.downloadFailed( - url: url, error: NSError(domain: "Invalid response type", code: -1) - ) - } - guard (200...299).contains(httpResponse.statusCode) else { - throw RegistryManagerError.invalidResponse(statusCode: httpResponse.statusCode) - } + @MainActor + func removeLanguageServer(packageName: String) async throws { + let packageName = packageName.removingPercentEncoding ?? packageName + let packageDirectory = installPath.appending(path: packageName) + print("Removing \(packageDirectory)") - return data - } catch { - if attempt <= 3 { - let delay = pow(2.0, Double(attempt)) - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - return try await download(from: url, attempt: attempt + 1) - } else { - throw RegistryManagerError.maxRetriesExceeded(url: url, lastError: error) - } + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + installedLanguageServers.removeValue(forKey: packageName) + return } - } - /// Loads registry items from disk - private func loadItemsFromDisk() -> [RegistryItem]? { - let registryPath = installPath.appending(path: "registry.json") - let fileManager = FileManager.default - - // Update the file every 24 hours - let needsUpdate = !fileManager.fileExists(atPath: registryPath.path) || { - guard let attributes = try? fileManager.attributesOfItem(atPath: registryPath.path), - let modificationDate = attributes[.modificationDate] as? Date else { - return true - } - let hoursSinceLastUpdate = Date().timeIntervalSince(modificationDate) / 3600 - return hoursSinceLastUpdate >= 24 - }() - - if needsUpdate { - Task { await update() } - return nil - } + // Add to activity viewer + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": packageName, + "action": "create", + "title": "Removing \(packageName)" + ] + ) do { - let registryData = try Data(contentsOf: registryPath) - let items = try JSONDecoder().decode([RegistryItem].self, from: registryData) - return items.filter { $0.categories.contains("LSP") } + try await Task.detached(priority: .userInitiated) { + try FileManager.default.removeItem(at: packageDirectory) + }.value + installedLanguageServers.removeValue(forKey: packageName) } catch { - Task { await update() } - return nil + throw error } } @@ -288,6 +190,27 @@ final class RegistryManager { ) } } + + /// Create the appropriate package manager for the given installation method + internal 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 + } + } } /// `CachedRegistry` is a timer based cache that will remove the registry items from memory diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index fc488d7f9..fc662ef90 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -22,6 +22,10 @@ struct LanguageServerRowView: View, Equatable { @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 init( packageName: String, @@ -92,6 +96,19 @@ struct LanguageServerRowView: View, Equatable { } } } + .alert("Remove \(cleanedTitle)?", isPresented: $showingRemovalConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + removeLanguageServer() + } + } message: { + Text("Are you sure you want to remove this language server? This action cannot be undone.") + } + .alert("Removal Failed", isPresented: $showingRemovalError) { + Button("OK", role: .cancel) { } + } message: { + Text(removalError?.localizedDescription ?? "An unknown error occurred") + } } @ViewBuilder @@ -115,9 +132,12 @@ struct LanguageServerRowView: View, Equatable { @ViewBuilder private func installedRow() -> some View { HStack { - if isHovering { + if isRemoving { + ProgressView() + .controlSize(.small) + } else if isHovering { Button { - isInstalled = false + showingRemovalConfirmation = true } label: { Text("Remove") } @@ -201,6 +221,26 @@ struct LanguageServerRowView: View, Equatable { .frame(width: iconSize, height: iconSize) } + private func removeLanguageServer() { + isRemoving = true + Task { + do { + try await RegistryManager.shared.removeLanguageServer(packageName: packageName) + await MainActor.run { + isRemoving = false + isInstalled = false + isEnabled = false + } + } catch { + await MainActor.run { + isRemoving = false + removalError = error + showingRemovalError = true + } + } + } + } + private var background: AnyShapeStyle { let colors: [Color] = [ .blue, .green, .orange, .red, .purple, .pink, .teal, .yellow, .indigo, .cyan diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index c7b7c0046..4ba88d026 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -12,6 +12,8 @@ struct LanguageServersView: View { @State private var installationFailure: InstallationFailure? @State private var registryItems: [RegistryItem] = [] @State private var isLoading = true + @State private var didRemoveError = false + @State private var removalFailure: InstallationFailure? var body: some View { SettingsForm { From b5d7186c82890c364f7e52335aa624db1e47fc5d Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 14 Mar 2025 05:25:16 -0700 Subject: [PATCH 16/38] Fix queue feedback and clean up memory --- .../Registry/InstallationQueueManager.swift | 141 ++++++++++++------ .../LSP/Registry/RegistryManager.swift | 2 - .../Extensions/LanguageServersView.swift | 5 +- 3 files changed, 95 insertions(+), 53 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift index 9ae3a4c6c..a833da397 100644 --- a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift +++ b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift @@ -16,74 +16,85 @@ class InstallationQueueManager { /// Queue of pending installations private var installationQueue: [(RegistryItem, (Result) -> Void)] = [] /// Currently running installations - private var runningInstallations: Int = 0 + private var runningInstallations: Set = [] /// Installation status dictionary private var installationStatus: [String: PackageInstallationStatus] = [:] - private init() {} - /// Add a package to the installation queue func queueInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { - installationStatus[package.name] = .queued - installationQueue.append((package, completion)) - processNextInstallations() + // 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 + // 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.queued] + 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 < maxConcurrentInstallations && !installationQueue.isEmpty { + while runningInstallations.count < maxConcurrentInstallations && !installationQueue.isEmpty { let (package, completion) = installationQueue.removeFirst() - runningInstallations += 1 - installationStatus[package.name] = .installing - - // 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] - ) + if runningInstallations.contains(package.name) { + continue } - 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 -= 1 - processNextInstallations() - } + startInstallation(package: package, completion: completion) } } @@ -91,6 +102,7 @@ class InstallationQueueManager { 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 { @@ -100,12 +112,43 @@ class InstallationQueueManager { 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 diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 17e644247..e977ec027 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -5,7 +5,6 @@ // Created by Abe Malla on 1/29/25. // -import Combine import Foundation import ZIPFoundation @@ -113,7 +112,6 @@ final class RegistryManager { func removeLanguageServer(packageName: String) async throws { let packageName = packageName.removingPercentEncoding ?? packageName let packageDirectory = installPath.appending(path: packageName) - print("Removing \(packageDirectory)") guard FileManager.default.fileExists(atPath: packageDirectory.path) else { installedLanguageServers.removeValue(forKey: packageName) diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift index 4ba88d026..09c977a9e 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -12,8 +12,6 @@ struct LanguageServersView: View { @State private var installationFailure: InstallationFailure? @State private var registryItems: [RegistryItem] = [] @State private var isLoading = true - @State private var didRemoveError = false - @State private var removalFailure: InstallationFailure? var body: some View { SettingsForm { @@ -59,6 +57,9 @@ struct LanguageServersView: View { .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in loadRegistryItems() } + .onDisappear { + InstallationQueueManager.shared.cleanUpInstallationStatus() + } .alert( "Installation Failed", isPresented: $didError, From 1db6a66e9773d2f23936d20215bcc61b089d0d3a Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 15 Mar 2025 05:37:09 -0700 Subject: [PATCH 17/38] Small refactors --- .../Features/LSP/Registry/InstallationQueueManager.swift | 2 +- CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift | 5 +++++ .../LSP/Registry/PackageManagers/CargoPackageManager.swift | 4 ++-- .../LSP/Registry/PackageManagers/GithubPackageManager.swift | 2 +- .../LSP/Registry/PackageManagers/GolangPackageManager.swift | 2 +- .../LSP/Registry/PackageManagers/NPMPackageManager.swift | 2 +- .../LSP/Registry/PackageManagers/PipPackageManager.swift | 2 +- CodeEdit/Features/LSP/Registry/RegistryManager.swift | 6 +----- .../Settings/Pages/Extensions/LanguageServerRowView.swift | 2 +- 9 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift index a833da397..b1f690960 100644 --- a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift +++ b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift @@ -8,7 +8,7 @@ import Foundation /// A class to manage queued installations of language servers -class InstallationQueueManager { +final class InstallationQueueManager { static let shared: InstallationQueueManager = .init() /// The maximum number of concurrent installations allowed diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift index 850e62f2a..85506f8ef 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -10,9 +10,14 @@ import Foundation 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 + /// 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 } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index ad4dd2004..e22bb44d6 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -7,7 +7,7 @@ import Foundation -class CargoPackageManager: PackageManagerProtocol { +final class CargoPackageManager: PackageManagerProtocol { private let installationDirectory: URL internal let shellClient: ShellClient @@ -80,7 +80,7 @@ class CargoPackageManager: PackageManagerProtocol { let output = versionOutput.reduce(into: "") { $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) } - return output.contains("cargo") + return output.starts(with: "cargo") } catch { print("Cargo version check failed: \(error)") return false diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index c5ca96921..c52d85bed 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -7,7 +7,7 @@ import Foundation -class GithubPackageManager: PackageManagerProtocol { +final class GithubPackageManager: PackageManagerProtocol { private let installationDirectory: URL internal let shellClient: ShellClient diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index 21bf0f1b2..a7be55b44 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -7,7 +7,7 @@ import Foundation -class GolangPackageManager: PackageManagerProtocol { +final class GolangPackageManager: PackageManagerProtocol { private let installationDirectory: URL internal let shellClient: ShellClient diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index f5e1114cb..1ccf32cb6 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -7,7 +7,7 @@ import Foundation -class NPMPackageManager: PackageManagerProtocol { +final class NPMPackageManager: PackageManagerProtocol { private let installationDirectory: URL internal let shellClient: ShellClient diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index d25ecece8..8b0843866 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -7,7 +7,7 @@ import Foundation -class PipPackageManager: PackageManagerProtocol { +final class PipPackageManager: PackageManagerProtocol { private let installationDirectory: URL internal let shellClient: ShellClient diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index e977ec027..ab91fbf86 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -12,11 +12,7 @@ import ZIPFoundation final class RegistryManager { static let shared: RegistryManager = .init() - internal let installPath = FileManager.default.homeDirectoryForCurrentUser - .appending(path: "Library") - .appending(path: "Application Support") - .appending(path: "CodeEdit") - .appending(path: "language-servers") + internal let installPath = Settings.shared.baseURL.appending(path: "language-servers") /// The URL of where the registry.json file will be downloaded from internal let registryURL = URL( diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift index fc662ef90..b1cdb7c6f 100644 --- a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -245,7 +245,7 @@ struct LanguageServerRowView: View, Equatable { let colors: [Color] = [ .blue, .green, .orange, .red, .purple, .pink, .teal, .yellow, .indigo, .cyan ] - let hashValue = abs(cleanedTitle.hashValue) % colors.count + let hashValue = abs(cleanedTitle.hash) % colors.count return AnyShapeStyle(colors[hashValue].gradient) } From 1ec3820ca9a95f387900bf2b1f8ce2dfcf5da0bf Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 15 Mar 2025 19:52:44 -0700 Subject: [PATCH 18/38] Temporarily removed Language Servers menu --- .../LSP/Registry/PackageManagerProtocol.swift | 10 +++++----- .../PackageSourceParser+Cargo.swift | 2 +- .../PackageSourceParser+Gem.swift | 2 +- .../PackageSourceParser+Golang.swift | 2 +- .../PackageSourceParser+NPM.swift | 2 +- .../PackageSourceParser+PYPI.swift | 2 +- .../PackageSourceParser/PackageSourceParser.swift | 2 +- .../Features/LSP/Registry/RegistryManager.swift | 2 +- CodeEdit/Features/Settings/SettingsView.swift | 14 +++++++------- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift index 85506f8ef..32e7e28e3 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -87,11 +87,6 @@ enum PackageManagerType: String, Codable { case github } -enum GitReference: Equatable, Codable { - case tag(String) - case revision(String) -} - /// Generic package source information that applies to all installation methods. /// Takes all the necessary information from `RegistryItem`. struct PackageSource: Equatable, Codable { @@ -131,6 +126,11 @@ struct PackageSource: Equatable, Codable { self.gitReference = gitReference self.options = options } + + enum GitReference: Equatable, Codable { + case tag(String) + case revision(String) + } } /// Installation method enum with all supported types diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift index 7eebccbf2..0b81d6a97 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -27,7 +27,7 @@ extension PackageSourceParser { // Parse parameters as options var options: [String: String] = ["buildTool": "cargo"] var repositoryUrl: String? - var gitReference: GitReference? + var gitReference: PackageSource.GitReference? let paramPairs = parameters.split(separator: "&") for pair in paramPairs { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift index 4f3711a57..1c2c7734a 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -27,7 +27,7 @@ extension PackageSourceParser { // Parse parameters as options var options: [String: String] = ["buildTool": "gem"] var repositoryUrl: String? - var gitReference: GitReference? + var gitReference: PackageSource.GitReference? let paramPairs = parameters.split(separator: "&") for pair in paramPairs { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift index 287afdcde..d75bf4970 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -34,7 +34,7 @@ extension PackageSourceParser { var options: [String: String] = ["buildTool": "golang"] options["subpath"] = subpath var repositoryUrl: String? - var gitReference: GitReference? + var gitReference: PackageSource.GitReference? let paramPairs = parameters.split(separator: "&") for pair in paramPairs { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift index 646f9fa7a..b5a63bb9e 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -24,7 +24,7 @@ extension PackageSourceParser { // Parse parameters as options var options: [String: String] = ["buildTool": "npm"] var repositoryUrl: String? - var gitReference: GitReference? + var gitReference: PackageSource.GitReference? let paramPairs = parameters.split(separator: "&") for pair in paramPairs { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift index c1b37f50e..bb41d7dc5 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+PYPI.swift @@ -27,7 +27,7 @@ extension PackageSourceParser { // Parse parameters as options var options: [String: String] = ["buildTool": "pip"] var repositoryUrl: String? - var gitReference: GitReference? + var gitReference: PackageSource.GitReference? let paramPairs = parameters.split(separator: "&") for pair in paramPairs { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift index 35d24dcf5..803d061fa 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -31,7 +31,7 @@ enum PackageSourceParser { let repositoryUrl = "https://github.com/\(owner)/\(repo)" let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil - let gitReference: GitReference = isCommitHash ? .revision(version) : .tag(version) + let gitReference: PackageSource.GitReference = isCommitHash ? .revision(version) : .tag(version) // Is this going to be built from source or downloaded let isSourceBuild = if entry.source.asset == nil { diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index ab91fbf86..0bce07684 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -12,7 +12,7 @@ import ZIPFoundation final class RegistryManager { static let shared: RegistryManager = .init() - internal let installPath = Settings.shared.baseURL.appending(path: "language-servers") + internal let installPath = Settings.shared.baseURL.appending(path: "Language Servers") /// The URL of where the registry.json file will be downloaded from internal let registryURL = URL( diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 1113bbf9d..66ecf8fb4 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -85,13 +85,13 @@ struct SettingsView: View { icon: .system("externaldrive.fill") ) ), - .init( - SettingsPage( - .languageServers, - baseColor: Color(hex: "#6A69DC"), // Purple - icon: .system("cube.box.fill") - ) - ), +// .init( +// SettingsPage( +// .languageServers, +// baseColor: Color(hex: "#6A69DC"), // Purple +// icon: .system("cube.box.fill") +// ) +// ), .init( SettingsPage( .developer, From 6747bf5c2fd09b1135c0c007ea47db5a0474f7e0 Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 31 Mar 2025 03:14:56 -0700 Subject: [PATCH 19/38] Small refactors --- .../LSP/Registry/PackageManagers/CargoPackageManager.swift | 5 ----- .../Registry/PackageManagers/GithubPackageManager.swift | 4 +--- .../Registry/PackageManagers/GolangPackageManager.swift | 6 ------ .../LSP/Registry/PackageManagers/NPMPackageManager.swift | 5 ----- .../LSP/Registry/PackageManagers/PipPackageManager.swift | 7 +------ 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index e22bb44d6..5e9721b52 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -35,8 +35,6 @@ final class CargoPackageManager: PackageManagerProtocol { } let packagePath = installationDirectory.appending(path: source.entryName) - print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") - try await initialize(in: packagePath) do { @@ -63,9 +61,7 @@ final class CargoPackageManager: PackageManagerProtocol { } _ = try await executeInDirectory(in: packagePath.path, cargoArgs) - print("Successfully installed \(source.entryName)@\(source.version)") } catch { - print("Installation failed: \(error)") throw error } } @@ -82,7 +78,6 @@ final class CargoPackageManager: PackageManagerProtocol { } return output.starts(with: "cargo") } catch { - print("Cargo version check failed: \(error)") return false } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index c52d85bed..889f6d19f 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -59,7 +59,6 @@ final class GithubPackageManager: PackageManagerProtocol { } return output.contains("git version") } catch { - print("Git version check failed: \(error)") return false } } @@ -73,7 +72,7 @@ final class GithubPackageManager: PackageManagerProtocol { if !FileManager.default.fileExists(atPath: packagePath.path) { throw RegistryManagerError.downloadFailed( url: url, - error: NSError(domain: "Coould not download package", code: -1) + error: NSError(domain: "Could not download package", code: -1) ) } @@ -89,7 +88,6 @@ final class GithubPackageManager: PackageManagerProtocol { let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) _ = try await executeInDirectory(in: repoPath.path, [command]) } catch { - print("Failed to build from source: \(error)") throw PackageManagerError.installationFailed("Source build failed.") } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index a7be55b44..eaf0cc2b3 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -43,8 +43,6 @@ final class GolangPackageManager: PackageManagerProtocol { } let packagePath = installationDirectory.appending(path: source.entryName) - print("Installing Go package \(source.entryName)@\(source.version) in \(packagePath.path)") - try await initialize(in: packagePath) do { @@ -78,10 +76,7 @@ final class GolangPackageManager: PackageManagerProtocol { let execPath = packagePath.appending(path: "bin").appending(path: binaryName).path _ = try await runCommand("chmod +x \"\(execPath)\"") } - - print("Successfully installed \(source.entryName)@\(source.version)") } catch { - print("Installation failed: \(error)") try? cleanupFailedInstallation(packagePath: packagePath) throw PackageManagerError.installationFailed(error.localizedDescription) } @@ -108,7 +103,6 @@ final class GolangPackageManager: PackageManagerProtocol { } return output.range(of: versionPattern, options: .regularExpression) != nil } catch { - print("Go version check failed: \(error)") return false } } diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index 1ccf32cb6..3dd61a85c 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -56,8 +56,6 @@ final class NPMPackageManager: PackageManagerProtocol { } let packagePath = installationDirectory.appending(path: source.entryName) - print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") - try await initialize(in: packagePath) do { @@ -73,10 +71,7 @@ final class NPMPackageManager: PackageManagerProtocol { _ = try await executeInDirectory(in: packagePath.path, installArgs) try verifyInstallation(folderName: source.entryName, package: source.pkgName, version: source.version) - - print("Successfully installed \(source.entryName)@\(source.version)") } catch { - print("Installation failed: \(error)") let nodeModulesPath = packagePath.appending(path: "node_modules").path try? FileManager.default.removeItem(atPath: nodeModulesPath) throw error diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index 8b0843866..f63237997 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -43,8 +43,6 @@ final class PipPackageManager: PackageManagerProtocol { } let packagePath = installationDirectory.appending(path: source.entryName) - print("Installing \(source.entryName)@\(source.version) in \(packagePath.path)") - try await initialize(in: packagePath) do { @@ -58,7 +56,7 @@ final class PipPackageManager: PackageManagerProtocol { } let extras = source.options["extra"] - if let extras = extras { + if let extras { if let lastIndex = installArgs.indices.last { installArgs[lastIndex] += "[\(extras)]" } @@ -67,10 +65,7 @@ final class PipPackageManager: PackageManagerProtocol { _ = try await executeInDirectory(in: packagePath.path, installArgs) try await updateRequirements(packagePath: packagePath) try await verifyInstallation(packagePath: packagePath, package: source.pkgName) - - print("Successfully installed \(source.entryName)@\(source.version)") } catch { - print("Installation failed: \(error)") throw error } } From 74bf7e5adaca80ea54dbc1f76aead9295781b903 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 9 Apr 2025 11:38:41 -0500 Subject: [PATCH 20/38] Correct CESE Version In XcodeProj File --- CodeEdit.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0979b4ed1..83a299923 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -1667,7 +1667,7 @@ repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.10.0; + minimumVersion = 0.11.0; }; }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { From 24b3c3701e506c95352d6a4e33b187b040454391 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:13:35 -0500 Subject: [PATCH 21/38] [chore:] Bump Version Number (#2021) Bumps the version number to `0.3.4` --- CodeEdit/Info.plist | 2 +- OpenWithCodeEdit/Info.plist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Info.plist b/CodeEdit/Info.plist index f6933b689..b4f251e04 100644 --- a/CodeEdit/Info.plist +++ b/CodeEdit/Info.plist @@ -1262,7 +1262,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.3.3 + 0.3.4 CFBundleURLTypes diff --git a/OpenWithCodeEdit/Info.plist b/OpenWithCodeEdit/Info.plist index 602726e0a..069637b0e 100644 --- a/OpenWithCodeEdit/Info.plist +++ b/OpenWithCodeEdit/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.3.3 + 0.3.4 CFBundleVersion 44 LSUIElement From 2fb11578045983a8bf1179cf222789dd99a11d15 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:27:39 -0500 Subject: [PATCH 22/38] Update pre-release.yml - xcpretty --- .github/workflows/pre-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index f4a851b31..40617ae75 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -48,7 +48,7 @@ jobs: - name: Build CodeEdit env: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: xcodebuild -scheme CodeEdit -configuration Pre -derivedDataPath "$RUNNER_TEMP/DerivedData" -archivePath "$RUNNER_TEMP/CodeEdit.xcarchive" -skipPackagePluginValidation DEVELOPMENT_TEAM=$APPLE_TEAM_ID archive + run: xcodebuild -scheme CodeEdit -configuration Pre -derivedDataPath "$RUNNER_TEMP/DerivedData" -archivePath "$RUNNER_TEMP/CodeEdit.xcarchive" -skipPackagePluginValidation DEVELOPMENT_TEAM=$APPLE_TEAM_ID archive | xcpretty ############################ # Sign From 8afeb08c7b4fbab1937780d0e329e1470ec2dc32 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:51:01 -0500 Subject: [PATCH 23/38] Bump Build Number to 45 (#2022) bump build number to 45 Co-authored-by: GitHub Action --- CodeEdit.xcodeproj/project.pbxproj | 50 +++++++++++++++--------------- CodeEdit/Info.plist | 2 +- OpenWithCodeEdit/Info.plist | 2 +- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 83a299923..3d42239b8 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -611,7 +611,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -651,7 +651,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -687,7 +687,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -715,7 +715,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -744,7 +744,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -806,7 +806,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -846,7 +846,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -882,7 +882,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -910,7 +910,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -939,7 +939,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -973,7 +973,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1007,7 +1007,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1070,7 +1070,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -1111,7 +1111,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1147,7 +1147,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -1175,7 +1175,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -1204,7 +1204,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1266,7 +1266,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1338,7 +1338,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -1377,7 +1377,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1417,7 +1417,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1453,7 +1453,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -1480,7 +1480,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -1508,7 +1508,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; @@ -1537,7 +1537,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 44; + CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; diff --git a/CodeEdit/Info.plist b/CodeEdit/Info.plist index b4f251e04..962625428 100644 --- a/CodeEdit/Info.plist +++ b/CodeEdit/Info.plist @@ -1275,7 +1275,7 @@ CFBundleVersion - 44 + 45 LSApplicationCategoryType public.app-category.developer-tools NSHumanReadableCopyright diff --git a/OpenWithCodeEdit/Info.plist b/OpenWithCodeEdit/Info.plist index 069637b0e..e592dfdf9 100644 --- a/OpenWithCodeEdit/Info.plist +++ b/OpenWithCodeEdit/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.4 CFBundleVersion - 44 + 45 LSUIElement NSExtension From 64ad6a397550c6d581ba29d94896ab269a3f9c2b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:43:50 -0500 Subject: [PATCH 24/38] Add workflow_dispatch to Release Notes CI --- .github/workflows/CI-release-notes.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI-release-notes.yml b/.github/workflows/CI-release-notes.yml index 7791a7e7a..6630abe0b 100644 --- a/.github/workflows/CI-release-notes.yml +++ b/.github/workflows/CI-release-notes.yml @@ -1,6 +1,7 @@ name: Deploy Website on Release Note Changes on: + workflow_dispatch: release: types: [created, edited, deleted] From a0d1fb94a5a5460454d4d6b1858b63048e9d2a67 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:01:55 -0500 Subject: [PATCH 25/38] docs: add pro100filipp as a contributor for code (#2023) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 +++ 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 8950a8b6b..f39c76143 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -785,6 +785,15 @@ "contributions": [ "bug" ] + }, + { + "login": "pro100filipp", + "name": "Filipp Kuznetsov", + "avatar_url": "https://avatars.githubusercontent.com/u/12880697?v=4", + "profile": "https://github.com/pro100filipp", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index f836f2fdc..4e75fe61f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,9 @@ For issues we want to focus on that are most relevant at any given time, please Savely
Savely

💻 Kihron
Kihron

🐛 + + Filipp Kuznetsov
Filipp Kuznetsov

💻 + From 29bf4452cc0139aa77805b3b73651eeba20ac785 Mon Sep 17 00:00:00 2001 From: Leonardo <83844690+LeonardoLarranaga@users.noreply.github.com> Date: Sat, 12 Apr 2025 06:25:12 -0700 Subject: [PATCH 26/38] Source Control Filter (#2024) ### Description Adding the ability to filter the project navigator by items that have a source control status. ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/2a5cb5a9-d4f0-42ff-9f61-2a7b3260ee5a --- .../WorkspaceDocument/WorkspaceDocument.swift | 2 ++ .../ProjectNavigatorOutlineView.swift | 4 +++ ...ewController+NSOutlineViewDataSource.swift | 7 +++-- .../ProjectNavigatorViewController.swift | 28 +++++++++++++------ .../ProjectNavigatorToolbarBottom.swift | 5 ++-- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 1b96e4fca..a47fdbba8 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -16,6 +16,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { @Published var sortFoldersOnTop: Bool = true /// A string used to filter the displayed files and folders in the project navigator area based on user input. @Published var navigatorFilter: String = "" + /// Whether the workspace only shows files with changes. + @Published var sourceControlFilter = false private var workspaceState: [String: Any] { get { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index 21cf99dd0..529280d09 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -67,6 +67,10 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in self?.controller?.handleFilterChange() } .store(in: &cancellables) + workspace.$sourceControlFilter + .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) + .sink { [weak self] _ in self?.controller?.handleFilterChange() } + .store(in: &cancellables) } var cancellables: Set = [] diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift index 9cd4f4e3d..727899663 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift @@ -15,8 +15,11 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { } if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { - if let filter = workspace?.navigatorFilter, !filter.isEmpty { - let filteredChildren = children.filter { fileSearchMatches(filter, for: $0) } + if let filter = workspace?.navigatorFilter, let sourceControlFilter = workspace?.sourceControlFilter, + !filter.isEmpty || sourceControlFilter { + let filteredChildren = children.filter { + fileSearchMatches(filter, for: $0, sourceControlFilter: sourceControlFilter) + } filteredContentChildren[item] = filteredChildren return filteredChildren } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index f76f07efc..ea9e2ee25 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -64,6 +64,10 @@ final class ProjectNavigatorViewController: NSViewController { /// to open the file a second time. var shouldSendSelectionUpdate: Bool = true + var filterIsEmpty: Bool { + workspace?.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true + } + /// Setup the ``scrollView`` and ``outlineView`` override func loadView() { self.scrollView = NSScrollView() @@ -193,13 +197,13 @@ final class ProjectNavigatorViewController: NSViewController { guard let workspace else { return } /// If the filter is empty, show all items and restore the expanded state. - if workspace.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - restoreExpandedState() - outlineView.autosaveExpandedItems = true - } else { + if workspace.sourceControlFilter || !filterIsEmpty { outlineView.autosaveExpandedItems = false /// Expand all items for search. outlineView.expandItem(outlineView.item(atRow: 0), expandChildren: true) + } else { + restoreExpandedState() + outlineView.autosaveExpandedItems = true } if let root = content.first(where: { $0.isRoot }), let children = filteredContentChildren[root] { @@ -213,16 +217,24 @@ final class ProjectNavigatorViewController: NSViewController { } /// Checks if the given filter matches the name of the item or any of its children. - func fileSearchMatches(_ filter: String, for item: CEWorkspaceFile) -> Bool { - guard !filter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } + func fileSearchMatches(_ filter: String, for item: CEWorkspaceFile, sourceControlFilter: Bool) -> Bool { + guard !filterIsEmpty || sourceControlFilter else { + return true + } - if item.name.localizedLowercase.contains(filter.localizedLowercase) { + if sourceControlFilter { + if item.gitStatus != nil && item.gitStatus != GitStatus.none && + (filterIsEmpty || item.name.localizedCaseInsensitiveContains(filter)) { + saveAllContentChildren(for: item) + return true + } + } else if item.name.localizedCaseInsensitiveContains(filter) { saveAllContentChildren(for: item) return true } if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { - return children.contains { fileSearchMatches(filter, for: $0) } + return children.contains { fileSearchMatches(filter, for: $0, sourceControlFilter: sourceControlFilter) } } return false diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index e2977bcac..c4a7758eb 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -18,7 +18,6 @@ struct ProjectNavigatorToolbarBottom: View { @EnvironmentObject var editorManager: EditorManager @State var recentsFilter: Bool = false - @State var sourceControlFilter: Bool = false var body: some View { HStack(spacing: 5) { @@ -48,7 +47,7 @@ struct ProjectNavigatorToolbarBottom: View { Image(systemName: "clock") } .help("Show only recent files") - Toggle(isOn: $sourceControlFilter) { + Toggle(isOn: $workspace.sourceControlFilter) { Image(systemName: "plusminus.circle") } .help("Show only files with source-control status") @@ -57,7 +56,7 @@ struct ProjectNavigatorToolbarBottom: View { .padding(.trailing, 2.5) }, clearable: true, - hasValue: !workspace.navigatorFilter.isEmpty || recentsFilter || sourceControlFilter + hasValue: !workspace.navigatorFilter.isEmpty || recentsFilter || workspace.sourceControlFilter ) } .padding(.horizontal, 5) From 3bafb1c9e9923dd40997af332dd43173a7def41c Mon Sep 17 00:00:00 2001 From: Leonardo <83844690+LeonardoLarranaga@users.noreply.github.com> Date: Wed, 16 Apr 2025 05:29:02 -0700 Subject: [PATCH 27/38] Disable 'Export All Custom Themes' if custom themes is empty (#2027) ### Description A quick PR... If the user doesn't have any custom themes, disable the "Export All Custom Themes...", as it still opens a save panel and lets the user choose a directory even though nothing's going to be saved. ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [ ] I documented my code ### Screenshots With no custom themes: Screenshot 2025-04-14 at 17 54 45 With custom themes: Screenshot 2025-04-14 at 17 55 11 --- .../Settings/Pages/ThemeSettings/ThemeSettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index 813479cdc..7ff0ceca7 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -53,6 +53,7 @@ struct ThemeSettingsView: View { } label: { Text("Export All Custom Themes...") } + .disabled(themeModel.themes.filter { !$0.isBundled }.isEmpty) } }) .padding(.horizontal, 5) From f2337c8e34fa1f78b7cee9993d2434e09953990f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 19 Apr 2025 14:52:41 -0500 Subject: [PATCH 28/38] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4e75fe61f..f5b9651ae 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ + + + From 14d9709f6ef2a41d6d41d8b70cd70bf00007d96e Mon Sep 17 00:00:00 2001 From: Leonardo <83844690+LeonardoLarranaga@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:05:26 -0700 Subject: [PATCH 29/38] Sort alphabetically - Folders on top (#2028) * Sort alphabetically - Folders on top * Changed `Button` to `Toggle` --- .../ProjectNavigatorOutlineView.swift | 10 +++++++--- ...iewController+NSOutlineViewDataSource.swift | 18 ++++++++++++++---- .../ProjectNavigatorToolbarBottom.swift | 13 +++++++++---- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index 529280d09..74d1f5320 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -65,11 +65,15 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { .store(in: &cancellables) workspace.$navigatorFilter .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) - .sink { [weak self] _ in self?.controller?.handleFilterChange() } + .sink { [weak self] _ in + self?.controller?.handleFilterChange() + } .store(in: &cancellables) - workspace.$sourceControlFilter + Publishers.Merge(workspace.$sourceControlFilter, workspace.$sortFoldersOnTop) .throttle(for: 0.1, scheduler: RunLoop.main, latest: true) - .sink { [weak self] _ in self?.controller?.handleFilterChange() } + .sink { [weak self] _ in + self?.controller?.handleFilterChange() + } .store(in: &cancellables) } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift index 727899663..2cc45c4b1 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift @@ -12,19 +12,29 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { private func getOutlineViewItems(for item: CEWorkspaceFile) -> [CEWorkspaceFile] { if let cachedChildren = filteredContentChildren[item] { return cachedChildren + .sorted { lhs, rhs in + workspace?.sortFoldersOnTop == true ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name + } } - if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { - if let filter = workspace?.navigatorFilter, let sourceControlFilter = workspace?.sourceControlFilter, - !filter.isEmpty || sourceControlFilter { + if let workspace, let children = workspace.workspaceFileManager?.childrenOfFile(item) { + if !workspace.navigatorFilter.isEmpty || workspace.sourceControlFilter { let filteredChildren = children.filter { - fileSearchMatches(filter, for: $0, sourceControlFilter: sourceControlFilter) + fileSearchMatches( + workspace.navigatorFilter, + for: $0, + sourceControlFilter: workspace.sourceControlFilter + ) } + filteredContentChildren[item] = filteredChildren return filteredChildren } return children + .sorted { lhs, rhs in + workspace.sortFoldersOnTop ? lhs.isFolder && !rhs.isFolder : lhs.name < rhs.name + } } return [] diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index c4a7758eb..bb1e44fb8 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -27,10 +27,15 @@ struct ProjectNavigatorToolbarBottom: View { text: $workspace.navigatorFilter, leadingAccessories: { FilterDropDownIconButton(menu: { - Button { - workspace.sortFoldersOnTop.toggle() - } label: { - Text(workspace.sortFoldersOnTop ? "Alphabetically" : "Folders on top") + ForEach([(true, "Folders on top"), (false, "Alphabetically")], id: \.0) { value, title in + Toggle(title, isOn: Binding(get: { + workspace.sortFoldersOnTop == value + }, set: { _ in + // Avoid calling the handleFilterChange method + if workspace.sortFoldersOnTop != value { + workspace.sortFoldersOnTop = value + } + })) } }, isOn: !workspace.navigatorFilter.isEmpty) .padding(.leading, 4) From 1ec781104ab6fb4de6145be68d6080161577a647 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 11:20:37 -0500 Subject: [PATCH 30/38] Upload dSYMs When Archiving --- .github/workflows/pre-release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 40617ae75..2bfce92e9 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -109,6 +109,20 @@ jobs: echo "APP_VERSION=$APP_VERSION" >> $GITHUB_ENV echo "APP_BUILD=$APP_BUILD" >> $GITHUB_ENV + ############################ + # Upload dSYMs Artifact + ############################ + - name: Upload dSYMs Artifact + uses: actions/upload-artifact@v4 + with: + name: "CodeEdit-${{ env.APP_BUILD }}-dSYMs" + path: "${{ RUNNER.TEMP }}/CodeEdit.xcarchive/dSYMs" + if-no-files-found: error + # overwrite files for the same build number + overwrite: true + # these can be big, use maximum compression + compression-level: 9 + ############################ # Sparkle Appcast ############################ From 32a3791ec9805ef999731a0e2a01ac585d64ed17 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:55:43 -0500 Subject: [PATCH 31/38] Fix Tasks Not Saving (#2034) ### Description Fixes a bug where tasks would not save after creating them due to an accidental change when removing a deprecated URL method. Also removes a few unnecessary view models in settings, and correctly handles errors thrown when saving workspace settings in UI (with a simple alert). ### Related Issues * N/A Reported on [discord](https://discord.com/channels/951544472238444645/952640521812193411/1362011473324540097) ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots --- .../Models/CEWorkspaceSettings.swift | 16 ++++++++++--- .../Views/CETaskFormView.swift | 6 ----- .../Views/CEWorkspaceSettingsView.swift | 3 --- .../Views/EditCETaskView.swift | 24 ++++++++++++------- .../CEWorkspaceSettingsTests.swift | 20 ++++++++++++++++ 5 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift index 8ff0b0249..21d5f661a 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift @@ -17,8 +17,8 @@ final class CEWorkspaceSettings: ObservableObject { private(set) var folderURL: URL - private var settingsURL: URL { - folderURL.appending(path: "settings").appending(path: "json") + var settingsURL: URL { + folderURL.appending(path: "settings").appendingPathExtension("json") } init(workspaceURL: URL) { @@ -54,7 +54,17 @@ final class CEWorkspaceSettings: ObservableObject { /// Save``CEWorkspaceSettingsManager`` model to `.codeedit/settings.json` func savePreferences() throws { // If the user doesn't have any settings to save, don't save them. - guard !settings.isEmpty() else { return } + guard !settings.isEmpty() else { + // Settings is empty, remove the file & directory if it's empty. + if fileManager.fileExists(atPath: settingsURL.path()) { + try fileManager.removeItem(at: settingsURL) + + if try fileManager.contentsOfDirectory(atPath: folderURL.path()).isEmpty { + try fileManager.removeItem(at: folderURL) + } + } + return + } if !fileManager.fileExists(atPath: folderURL.path()) { try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true) diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift index f3f47a3eb..6e7f84057 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift @@ -12,7 +12,6 @@ struct CETaskFormView: View { @ObservedObject var task: CETask @State private var selectedEnvID: UUID? - @StateObject var settingsViewModel = SettingsViewModel() var body: some View { Form { Section { @@ -85,7 +84,6 @@ struct CETaskFormView: View { } } .formStyle(.grouped) - .environmentObject(settingsViewModel) } func removeSelectedEnv() { @@ -100,7 +98,3 @@ struct CETaskFormView: View { }) } } - -// #Preview { -// CETaskFormView() -// } diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift index 682d63b25..451ed2a38 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift @@ -13,8 +13,6 @@ struct CEWorkspaceSettingsView: View { @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings @EnvironmentObject var workspace: WorkspaceDocument - @StateObject var settingsViewModel = SettingsViewModel() - @State var selectedTaskID: UUID? @State var showAddTaskSheet: Bool = false @@ -68,7 +66,6 @@ struct CEWorkspaceSettingsView: View { } .padding() } - .environmentObject(settingsViewModel) .sheet(isPresented: $showAddTaskSheet) { if let selectedTaskIndex = workspaceSettingsManager.settings.tasks.firstIndex(where: { $0.id == selectedTaskID diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift index 3efbec03f..8d12b39f5 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift @@ -23,12 +23,16 @@ struct EditCETaskView: View { Divider() HStack { Button(role: .destructive) { - workspaceSettingsManager.settings.tasks.removeAll(where: { - $0.id == task.id - }) - try? workspaceSettingsManager.savePreferences() - taskManager.deleteTask(taskID: task.id) - self.dismiss() + do { + workspaceSettingsManager.settings.tasks.removeAll(where: { + $0.id == task.id + }) + try workspaceSettingsManager.savePreferences() + taskManager.deleteTask(taskID: task.id) + self.dismiss() + } catch { + NSAlert(error: error).runModal() + } } label: { Text("Delete") .foregroundStyle(.red) @@ -38,8 +42,12 @@ struct EditCETaskView: View { Spacer() Button { - try? workspaceSettingsManager.savePreferences() - self.dismiss() + do { + try workspaceSettingsManager.savePreferences() + self.dismiss() + } catch { + NSAlert(error: error).runModal() + } } label: { Text("Done") .frame(minWidth: 56) diff --git a/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift b/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift new file mode 100644 index 000000000..814172490 --- /dev/null +++ b/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift @@ -0,0 +1,20 @@ +// +// CEWorkspaceSettingsTests.swift +// CodeEditTests +// +// Created by Khan Winter on 4/21/25. +// + +import Foundation +import Testing +@testable import CodeEdit + +struct CEWorkspaceSettingsTests { + let settings: CEWorkspaceSettings = CEWorkspaceSettings(workspaceURL: URL(filePath: "/")) + + @Test + func settingsURLNoSpace() async throws { + #expect(settings.folderURL.lastPathComponent == ".codeedit") + #expect(settings.settingsURL.lastPathComponent == "settings.json") + } +} From 89c6cc59ca1113381477b37c853a0e36b9681ecf Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 26 Apr 2025 20:53:31 -0500 Subject: [PATCH 32/38] Add A Minimap (#2032) Adds a minimap to CodeEdit's editor, as well as a new trailing editor accessory (that only appears when the selected document is a code document), a command to toggle the minimap, and a setting to toggle the minimap. * https://github.com/CodeEditApp/CodeEditSourceEditor/issues/33 - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code https://github.com/user-attachments/assets/07f21d48-23cf-42dc-b39a-02ba395956cb --- CodeEdit.xcodeproj/project.pbxproj | 11 ++++- .../xcshareddata/swiftpm/Package.resolved | 8 ++-- .../JumpBar/Views/EditorJumpBarView.swift | 6 ++- .../EditorTabBarTrailingAccessories.swift | 46 +++++++++++++++++-- .../TabBar/Views/EditorTabBarView.swift | 3 +- .../Features/Editor/Views/CodeFileView.swift | 5 +- .../Editor/Views/EditorAreaView.swift | 5 +- .../Models/TextEditingSettings.swift | 20 ++++++-- .../TextEditingSettingsView.swift | 7 +++ 9 files changed, 92 insertions(+), 19 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 3d42239b8..b2fe76d2a 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -423,8 +423,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 30C549D82D77BDF8008DDEF8 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1774,6 +1773,14 @@ version = 1.0.1; }; }; + 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.12.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 20b81b226..5d53d71e3 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor.git", "state" : { - "revision" : "f444927ab70015f4b76f119f6fc5d0e358fcd77a", - "version" : "0.11.0" + "revision" : "412b0a26cbeb3f3148a1933dd598c976defe92a6", + "version" : "0.12.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", - "version" : "0.8.2" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift b/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift index 5d719e72c..ce53d07b8 100644 --- a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift +++ b/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift @@ -21,15 +21,19 @@ struct EditorJumpBarView: View { @Environment(\.controlActiveState) private var activeState + @Binding var codeFile: CodeFileDocument? + static let height = 28.0 init( file: CEWorkspaceFile?, shouldShowTabBar: Bool, + codeFile: Binding, tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.file = file ?? nil self.shouldShowTabBar = shouldShowTabBar + self._codeFile = codeFile self.tappedOpenFile = tappedOpenFile } @@ -75,7 +79,7 @@ struct EditorJumpBarView: View { } .safeAreaInset(edge: .trailing, spacing: 0) { if !shouldShowTabBar { - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: $codeFile) } } .frame(height: Self.height, alignment: .center) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift index 796f661aa..156983061 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift @@ -8,6 +8,11 @@ import SwiftUI struct EditorTabBarTrailingAccessories: View { + @AppSettings(\.textEditing.wrapLinesToEditorWidth) + var wrapLinesToEditorWidth + @AppSettings(\.textEditing.showMinimap) + var showMinimap + @Environment(\.splitEditor) var splitEditor @@ -21,15 +26,49 @@ struct EditorTabBarTrailingAccessories: View { @EnvironmentObject private var editor: Editor + @Binding var codeFile: CodeFileDocument? + var body: some View { - HStack(spacing: 0) { + HStack(spacing: 6) { + // Once more options are implemented that are available for non-code documents, remove this if statement + if let codeFile { + editorOptionsMenu(codeFile: codeFile) + Divider() + .padding(.vertical, 10) + } splitviewButton } + .buttonStyle(.icon) + .disabled(editorManager.isFocusingActiveEditor) + .opacity(editorManager.isFocusingActiveEditor ? 0.5 : 1) .padding(.horizontal, 7) .opacity(activeState != .inactive ? 1.0 : 0.5) .frame(maxHeight: .infinity) // Fill out vertical spaces. } + func editorOptionsMenu(codeFile: CodeFileDocument) -> some View { + // This is a button so it gets the same styling from the Group in `body`. + Button(action: {}, label: { Image(systemName: "slider.horizontal.3") }) + .overlay { + Menu { + Toggle("Show Minimap", isOn: $showMinimap) + .keyboardShortcut("M", modifiers: [.command, .shift, .control]) + Divider() + Toggle( + "Wrap Lines", + isOn: Binding( + get: { codeFile.wrapLines ?? wrapLinesToEditorWidth }, + set: { + codeFile.wrapLines = $0 + } + ) + ) + } label: {} + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + } + } + var splitviewButton: some View { Group { switch (editor.parent?.axis, modifierKeys.contains(.option)) { @@ -53,9 +92,6 @@ struct EditorTabBarTrailingAccessories: View { EmptyView() } } - .buttonStyle(.icon) - .disabled(editorManager.isFocusingActiveEditor) - .opacity(editorManager.isFocusingActiveEditor ? 0.5 : 1) } func split(edge: Edge) { @@ -73,6 +109,6 @@ struct EditorTabBarTrailingAccessories: View { struct TabBarTrailingAccessories_Previews: PreviewProvider { static var previews: some View { - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: .constant(nil)) } } diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift index 879a99020..e080d1dff 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift @@ -9,6 +9,7 @@ import SwiftUI struct EditorTabBarView: View { let hasTopInsets: Bool + @Binding var codeFile: CodeFileDocument? /// The height of tab bar. /// I am not making it a private variable because it may need to be used in outside views. static let height = 28.0 @@ -21,7 +22,7 @@ struct EditorTabBarView: View { .accessibilityElement(children: .contain) .accessibilityLabel("Tab Bar") .accessibilityIdentifier("TabBar") - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: $codeFile) .padding(.top, hasTopInsets ? -1 : 0) } .frame(height: EditorTabBarView.height - (hasTopInsets ? 1 : 0)) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index f45f4d107..eeb8586cf 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -44,6 +44,8 @@ struct CodeFileView: View { var bracketEmphasis @AppSettings(\.textEditing.useSystemCursor) var useSystemCursor + @AppSettings(\.textEditing.showMinimap) + var showMinimap @Environment(\.colorScheme) private var colorScheme @@ -125,7 +127,8 @@ struct CodeFileView: View { bracketPairEmphasis: getBracketPairEmphasis(), useSystemCursor: useSystemCursor, undoManager: undoManager, - coordinators: textViewCoordinators + coordinators: textViewCoordinators, + showMinimap: showMinimap ) .id(codeFile.fileURL) .background { diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 5e3dd8557..96ff8bf21 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -104,7 +104,7 @@ struct EditorAreaView: View { .background(.clear) } if shouldShowTabBar { - EditorTabBarView(hasTopInsets: topSafeArea > 0) + EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: $codeFile) .id("TabBarView" + editor.id.uuidString) .environmentObject(editor) Divider() @@ -112,7 +112,8 @@ struct EditorAreaView: View { if showEditorJumpBar { EditorJumpBarView( file: editor.selectedTab?.file, - shouldShowTabBar: shouldShowTabBar + shouldShowTabBar: shouldShowTabBar, + codeFile: $codeFile ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { editor?.openTab(file: newFile, at: index) diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index 26e760f1e..3fa6a6bfc 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -27,7 +27,8 @@ extension SettingsData { "Autocomplete braces", "Enable type-over completion", "Bracket Pair Emphasis", - "Bracket Pair Highlight" + "Bracket Pair Highlight", + "Show Minimap", ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -70,6 +71,9 @@ extension SettingsData { /// Use the system cursor for the source editor. var useSystemCursor: Bool = true + /// Toggle the minimap in the editor. + var showMinimap: Bool = true + /// Default initializer init() { self.populateCommands() @@ -118,6 +122,8 @@ extension SettingsData { self.useSystemCursor = false } + self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true + self.populateCommands() } @@ -130,7 +136,7 @@ extension SettingsData { title: "Toggle Type-Over Completion", id: "prefs.text_editing.type_over_completion", command: { - Settings.shared.preferences.textEditing.enableTypeOverCompletion.toggle() + Settings[\.textEditing].enableTypeOverCompletion.toggle() } ) @@ -139,7 +145,7 @@ extension SettingsData { title: "Toggle Autocomplete Braces", id: "prefs.text_editing.autocomplete_braces", command: { - Settings.shared.preferences.textEditing.autocompleteBraces.toggle() + Settings[\.textEditing].autocompleteBraces.toggle() } ) @@ -151,6 +157,14 @@ extension SettingsData { Settings[\.textEditing].wrapLinesToEditorWidth.toggle() } ) + + mgr.addCommand( + name: "Toggle Minimap", + title: "Toggle Minimap", + id: "prefs.text_editing.toggle_minimap" + ) { + Settings[\.textEditing].showMinimap.toggle() + } } struct IndentOption: Codable, Hashable { diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index 03b78a8c0..fdaeeaaf4 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -20,6 +20,7 @@ struct TextEditingSettingsView: View { wrapLinesToEditorWidth useSystemCursor overscroll + showMinimap } Section { fontSelector @@ -199,4 +200,10 @@ private extension TextEditingSettingsView { } } } + + @ViewBuilder private var showMinimap: some View { + Toggle("Show Minimap", isOn: $textEditing.showMinimap) + // swiftlint:disable:next line_length + .help("The minimap gives you a high-level summary of your source code, with controls to quickly navigate your document.") + } } From e4646030a861f570b287cb713de711deed231293 Mon Sep 17 00:00:00 2001 From: Kihron Date: Tue, 6 May 2025 13:47:09 -0400 Subject: [PATCH 33/38] Fix Getting Stuck in Sub-View within Settings (#2038) ### Description This pull request resolves several navigation related bugs within Settings. The most important being that it resolves getting suck within sub views. Additionally, I've improved how hiding the sidebar toggle works which is now consistently hidden regardless of macOS 13+. It now no longer makes unexpected returns or cameos. ### Related Issues * closes #1923 ### Checklist - [X] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [X] The issues this PR addresses are related to each other - [X] My changes generate no new warnings - [X] My code builds and runs on my machine - [X] My changes are all related to the related issue above - [X] I documented my code ### Screenshots https://github.com/user-attachments/assets/f92bcc49-b397-4e33-85c5-06d0873e6b10 --- CodeEdit/Features/Settings/SettingsView.swift | 2 +- .../Settings/Views/SettingsForm.swift | 58 ++++++++++--------- .../Views/View+HideSidebarToggle.swift | 12 ++-- .../View+NavigationBarBackButtonVisible.swift | 6 +- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 66ecf8fb4..d4b6b67aa 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -193,11 +193,11 @@ struct SettingsView: View { } } .navigationSplitViewColumnWidth(500) - .hideSidebarToggle() .onAppear { model.backButtonVisible = false } } + .hideSidebarToggle() .navigationTitle(selectedPage.name.rawValue) .toolbar { ToolbarItem(placement: .navigation) { diff --git a/CodeEdit/Features/Settings/Views/SettingsForm.swift b/CodeEdit/Features/Settings/Views/SettingsForm.swift index 14faaaa61..ee093864a 100644 --- a/CodeEdit/Features/Settings/Views/SettingsForm.swift +++ b/CodeEdit/Features/Settings/Views/SettingsForm.swift @@ -17,39 +17,41 @@ struct SettingsForm: View { @ViewBuilder var content: Content var body: some View { - Form { - Section { - EmptyView() - } footer: { - Rectangle() - .frame(height: 0) - .background( - GeometryReader { - Color.clear.preference( - key: ViewOffsetKey.self, - value: -$0.frame(in: .named("scroll")).origin.y - ) - } - ) - .onPreferenceChange(ViewOffsetKey.self) { - if $0 <= -20.0 && !model.scrolledToTop { - withAnimation { - model.scrolledToTop = true + NavigationStack { + Form { + Section { + EmptyView() + } footer: { + Rectangle() + .frame(height: 0) + .background( + GeometryReader { + Color.clear.preference( + key: ViewOffsetKey.self, + value: -$0.frame(in: .named("scroll")).origin.y + ) } - } else if $0 > -20.0 && model.scrolledToTop { - withAnimation { - model.scrolledToTop = false + ) + .onPreferenceChange(ViewOffsetKey.self) { + if $0 <= -20.0 && !model.scrolledToTop { + withAnimation { + model.scrolledToTop = true + } + } else if $0 > -20.0 && model.scrolledToTop { + withAnimation { + model.scrolledToTop = false + } } } - } + } + content } - content - } - .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15)) { - $0.scrollerInsets.top = 50 + .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15)) { + $0.scrollerInsets.top = 50 + } + .formStyle(.grouped) + .coordinateSpace(name: "scroll") } - .formStyle(.grouped) - .coordinateSpace(name: "scroll") .safeAreaInset(edge: .top, spacing: -50) { EffectView(.menu) .opacity(!model.scrolledToTop ? 1 : 0) diff --git a/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift b/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift index 09b65c858..b48c81d30 100644 --- a/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift +++ b/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftUIIntrospect extension View { func hideSidebarToggle() -> some View { @@ -16,12 +17,11 @@ extension View { struct HideSidebarToggleViewModifier: ViewModifier { func body(content: Content) -> some View { content - .task { - let window = NSApp.windows.first { $0.identifier?.rawValue == SceneID.settings.rawValue }! - let sidebaritem = "com.apple.SwiftUI.navigationSplitView.toggleSidebar" - let index = window.toolbar?.items.firstIndex { $0.itemIdentifier.rawValue == sidebaritem } - if let index { - window.toolbar?.removeItem(at: index) + .introspect(.window, on: .macOS(.v13, .v14, .v15)) { window in + if let toolbar = window.toolbar { + let sidebarItem = "com.apple.SwiftUI.navigationSplitView.toggleSidebar" + let sidebarToggle = toolbar.items.first(where: { $0.itemIdentifier.rawValue == sidebarItem }) + sidebarToggle?.view?.isHidden = true } } } diff --git a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift index 325a0b123..a9ab0ea9f 100644 --- a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift +++ b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift @@ -17,7 +17,6 @@ struct NavigationBarBackButtonVisible: ViewModifier { .toolbar { ToolbarItem(placement: .navigation) { Button { - print(self.presentationMode.wrappedValue) self.presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "chevron.left") @@ -25,10 +24,13 @@ struct NavigationBarBackButtonVisible: ViewModifier { } } } - .hideSidebarToggle() + .navigationBarBackButtonHidden() .onAppear { model.backButtonVisible = true } + .onDisappear { + model.backButtonVisible = false + } } } From 3ca4a3a870c04f287797ab9bd88d3c4544879acd Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 9 May 2025 13:57:56 -0500 Subject: [PATCH 34/38] Fixed History Inspector popover UI bug (#2041) --- .../InspectorArea/HistoryInspector/HistoryPopoverView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift index 1913af453..704eddb2c 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift @@ -18,10 +18,8 @@ struct HistoryPopoverView: View { var body: some View { VStack { CommitDetailsHeaderView(commit: commit) - .padding(.horizontal) Divider() - .padding(.horizontal) VStack(alignment: .leading, spacing: 0) { // TODO: Implementation Needed @@ -71,6 +69,8 @@ struct HistoryPopoverView: View { }, icon: { Image(systemName: image) .frame(width: 16, alignment: .center) + .padding(.leading, -2.5) + .padding(.trailing, 2.5) }) .frame(maxWidth: .infinity, alignment: .leading) .foregroundColor(isHovering && isEnabled ? .white : .primary) From d92d6c6ff73e0b87c61faff150c802b0918f499c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 9 May 2025 14:51:55 -0500 Subject: [PATCH 35/38] Language Server Syntax Highlights (#1985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds semantic token syntax highlighting to the code file view. When an LSP is installed and configured for a language type, and has semantic highlights support, CodeEdit will install a new highlight provider on the source editor and begin processing syntax tokens for the file. Token processing happens asynchronously, and does **not** replace tree-sitter highlights. This builds off recent work in the source editor to support a hierarchy of highlight providers. Language server highlights are slow but more accurate, so we process them slowly and apply them when they become available. - Adds a new generic 'language server document' protocol that includes only what the language server code needs to know about a code document. This should solve the coupling issue we had with CodeFileDocument and the language server code. In the future, if we replace `CodeFileDocument`, it'll be a matter of conforming the new type to the protocol for it to work with the lsp code. - Reorganizes slightly to group lsp features into their own "Features" folder. - Adds a new `SemanticTokenHighlightProvider` type - Conforms to the `HighlightProviding` protocol. - Manages receiving edit notifications from the editor and forwards them to the language server service. - Adds a `SemanticTokenMap` type - Maps LSP semantic token data to a format CodeEdit can read. - Reads a LSP's capabilities to determine how to decode that data. - Adds `SemanticTokenStorage` - Provides an API for applying token deltas, and entire file token data. - Manages decoding, re-decoding (when dealing with deltas) and storing semantic tokens. - Provides an API for finding semantic tokens quickly. * closes #1950 - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ![Screenshot 2025-02-14 at 10 20 09 AM](https://github.com/user-attachments/assets/14ee65a3-058c-4f9c-b816-ae258aca96be) Live editing demo, note the highlights on the variable types and switch cases. https://github.com/user-attachments/assets/e70bf93c-779d-412b-9b34-c68e46898921 --- .../CodeFileDocument/CodeFileDocument.swift | 23 +- .../Features/Editor/Views/CodeFileView.swift | 21 +- .../DocumentSync}/LSPContentCoordinator.swift | 18 +- .../SemanticTokenHighlightProvider.swift | 172 +++++++++++++++ .../SemanticTokens}/SemanticTokenMap.swift | 19 +- .../SemanticTokenMapRangeProvider.swift | 0 .../GenericSemanticTokenStorage.swift | 25 +++ .../SemanticTokenRange.swift | 13 ++ .../SemanticTokenStorage.swift | 180 ++++++++++++++++ .../LanguageServer+DocumentSync.swift | 37 +++- .../LanguageServer+SemanticTokens.swift | 18 +- .../LSP/LanguageServer/LanguageServer.swift | 15 +- .../LanguageServerFileMap.swift | 53 +++-- .../Features/LSP/LanguageServerDocument.swift | 23 ++ .../Features/LSP/Service/LSPService.swift | 40 ++-- .../LSP/Service/LSPServiceError.swift | 13 ++ .../SemanticToken+Position.swift | 18 ++ .../TextView+SemanticTokenRangeProvider.swift | 5 + .../Utils/Extensions/URL/URL+LSPURI.swift | 18 ++ ... => LanguageServer+CodeFileDocument.swift} | 112 +++++----- .../LSP/LanguageServer+DocumentObjects.swift | 80 +++++++ .../SemanticTokenMapTests.swift | 30 +-- .../SemanticTokenStorageTests.swift | 199 ++++++++++++++++++ 23 files changed, 982 insertions(+), 150 deletions(-) rename CodeEdit/Features/LSP/{Editor => Features/DocumentSync}/LSPContentCoordinator.swift (87%) create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift rename CodeEdit/Features/LSP/{Editor => Features/SemanticTokens}/SemanticTokenMap.swift (80%) rename CodeEdit/Features/LSP/{Editor => Features/SemanticTokens}/SemanticTokenMapRangeProvider.swift (100%) create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift create mode 100644 CodeEdit/Features/LSP/LanguageServerDocument.swift create mode 100644 CodeEdit/Features/LSP/Service/LSPServiceError.swift create mode 100644 CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift create mode 100644 CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift rename CodeEditTests/Features/LSP/{LanguageServer+DocumentTests.swift => LanguageServer+CodeFileDocument.swift} (80%) create mode 100644 CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift rename CodeEditTests/Features/LSP/{ => SemanticTokens}/SemanticTokenMapTests.swift (79%) create mode 100644 CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 8fd36004b..2b3fcfd24 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -50,9 +50,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// See ``CodeEditSourceEditor/CombineCoordinator``. @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() - /// Set by ``LanguageServer`` when initialized. - @Published var lspCoordinator: LSPContentCoordinator? - /// Used to override detected languages. @Published var language: CodeLanguage? @@ -65,6 +62,9 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// Document-specific overridden line wrap preference. @Published var wrapLines: Bool? + /// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``. + @Published var languageServerObjects: LanguageServerDocumentObjects = .init() + /// The type of data this file document contains. /// /// If its text content is not nil, a `text` UTType is returned. @@ -83,9 +83,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { return type } - /// A stable string to use when identifying documents with language servers. - var languageServerURI: String? { fileURL?.absolutePath } - /// Specify options for opening the file such as the initial cursor positions. /// Nulled by ``CodeFileView`` on first load. var openOptions: OpenOptions? @@ -208,6 +205,10 @@ final class CodeFileDocument: NSDocument, ObservableObject { } } + /// Determines the code language of the document. + /// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override + /// the file's language. + /// - Returns: The detected code language. func getLanguage() -> CodeLanguage { guard let url = fileURL else { return .default @@ -223,3 +224,13 @@ final class CodeFileDocument: NSDocument, ObservableObject { fileURL?.findWorkspace() } } + +// MARK: LanguageServerDocument + +extension CodeFileDocument: LanguageServerDocument { + /// A stable string to use when identifying documents with language servers. + /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. + var languageServerURI: String? { + fileURL?.lspURI + } +} diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index eeb8586cf..ae5e167ad 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -19,9 +19,13 @@ struct CodeFileView: View { /// The current cursor positions in the view @State private var cursorPositions: [CursorPosition] = [] + @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() + /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] + @State private var highlightProviders: [any HighlightProviding] = [] + @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @AppSettings(\.textEditing.indentOption) @@ -62,9 +66,10 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) + self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator] - + [codeFile.lspCoordinator].compactMap({ $0 }) + + [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 }) self.isEditable = isEditable if let openOptions = codeFile.openOptions { @@ -72,6 +77,8 @@ struct CodeFileView: View { self.cursorPositions = openOptions.cursorPositions } + updateHighlightProviders() + codeFile .contentCoordinator .textUpdatePublisher @@ -119,7 +126,7 @@ struct CodeFileView: View { editorOverscroll: overscroll.overscrollPercentage, cursorPositions: $cursorPositions, useThemeBackground: useThemeBackground, - highlightProviders: [treeSitter], + highlightProviders: highlightProviders, contentInsets: edgeInsets.nsEdgeInsets, additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0), isEditable: isEditable, @@ -144,6 +151,10 @@ struct CodeFileView: View { .onChange(of: settingsFont) { newFontSetting in font = newFontSetting.current } + .onReceive(codeFile.$languageServerObjects) { languageServerObjects in + // This will not be called in single-file views (for now) but is safe to listen to either way + updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider) + } } /// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme. @@ -166,6 +177,12 @@ struct CodeFileView: View { return .underline(color: color) } } + + /// Updates the highlight providers array. + /// - Parameter lspHighlightProvider: The language server provider, if available. + private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) { + highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient] + } } // This extension is kept here because it should not be used elsewhere in the app and may cause confusion diff --git a/CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift similarity index 87% rename from CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift rename to CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift index dc17481e6..aed2a7ada 100644 --- a/CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift +++ b/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift @@ -19,7 +19,7 @@ import LanguageServerProtocol /// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class /// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then /// chunked into 250ms timed groups before being sent to the ``LanguageServer``. -class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { +class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { // Required to avoid a large_tuple lint error private struct SequenceElement: Sendable { let uri: String @@ -28,25 +28,27 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { } private var editedRange: LSPRange? - private var stream: AsyncStream? private var sequenceContinuation: AsyncStream.Continuation? private var task: Task? - weak var languageServer: LanguageServer? + weak var languageServer: LanguageServer? var documentURI: String /// Initializes a content coordinator, and begins an async stream of updates - init(documentURI: String, languageServer: LanguageServer) { + init(documentURI: String, languageServer: LanguageServer) { self.documentURI = documentURI self.languageServer = languageServer - self.stream = AsyncStream { continuation in - self.sequenceContinuation = continuation - } + + setUpUpdatesTask() } func setUpUpdatesTask() { task?.cancel() - guard let stream else { return } + // Create this stream here so it's always set up when the text view is set up, rather than only once on init. + let stream = AsyncStream { continuation in + self.sequenceContinuation = continuation + } + task = Task.detached { [weak self] in // Send edit events every 250ms for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) { diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift new file mode 100644 index 000000000..2e391fba4 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift @@ -0,0 +1,172 @@ +// +// SemanticTokenHighlightProvider.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor +import CodeEditTextView +import CodeEditLanguages + +/// Provides semantic token information from a language server for a source editor view. +/// +/// This class works in tangent with the ``LanguageServer`` class to ensure we don't unnecessarily request new tokens +/// if the document isn't updated. The ``LanguageServer`` will call the +/// ``SemanticTokenHighlightProvider/documentDidChange`` method, which in turn refreshes the semantic token storage. +/// +/// That behavior may not be intuitive due to the +/// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class +/// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until +/// it can respond to the edit with invalidated indices. +final class SemanticTokenHighlightProvider< + Storage: GenericSemanticTokenStorage, + DocumentType: LanguageServerDocument +>: HighlightProviding { + enum HighlightError: Error { + case lspRangeFailure + } + + typealias EditCallback = @MainActor (Result) -> Void + typealias HighlightCallback = @MainActor (Result<[HighlightRange], any Error>) -> Void + + private let tokenMap: SemanticTokenMap + private let documentURI: String + private weak var languageServer: LanguageServer? + private weak var textView: TextView? + + private var lastEditCallback: EditCallback? + private var pendingHighlightCallbacks: [HighlightCallback] = [] + private var storage: Storage + + var documentRange: NSRange { + textView?.documentRange ?? .zero + } + + init(tokenMap: SemanticTokenMap, languageServer: LanguageServer, documentURI: String) { + self.tokenMap = tokenMap + self.languageServer = languageServer + self.documentURI = documentURI + self.storage = Storage() + } + + // MARK: - Language Server Content Lifecycle + + /// Called when the language server finishes sending a document update. + /// + /// This method first checks if this object has any semantic tokens. If not, requests new tokens and responds to the + /// `pendingHighlightCallbacks` queue with cancellation errors, causing the highlighter to re-query those indices. + /// + /// If this object already has some tokens, it determines whether or not we can request a token delta and + /// performs the request. + func documentDidChange() async throws { + guard let languageServer, let textView else { + return + } + + guard storage.hasReceivedData else { + // We have no semantic token info, request it! + try await requestTokens(languageServer: languageServer, textView: textView) + await MainActor.run { + for callback in pendingHighlightCallbacks { + callback(.failure(HighlightProvidingError.operationCancelled)) + } + pendingHighlightCallbacks.removeAll() + } + return + } + + // The document was updated. Update our token cache and send the invalidated ranges for the editor to handle. + if let lastResultId = storage.lastResultId { + try await requestDeltaTokens(languageServer: languageServer, textView: textView, lastResultId: lastResultId) + return + } + + try await requestTokens(languageServer: languageServer, textView: textView) + } + + // MARK: - LSP Token Requests + + /// Requests and applies a token delta. Requires a previous response identifier. + private func requestDeltaTokens( + languageServer: LanguageServer, + textView: TextView, + lastResultId: String + ) async throws { + guard let response = try await languageServer.requestSemanticTokens( + for: documentURI, + previousResultId: lastResultId + ) else { + return + } + switch response { + case let .optionA(tokenData): + await applyEntireResponse(tokenData, callback: lastEditCallback) + case let .optionB(deltaData): + await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView) + } + } + + /// Requests and applies tokens for an entire document. This does not require a previous response id, and should be + /// used in place of `requestDeltaTokens` when that's the case. + private func requestTokens(languageServer: LanguageServer, textView: TextView) async throws { + guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else { + return + } + await applyEntireResponse(response, callback: lastEditCallback) + } + + // MARK: - Apply LSP Response + + /// Applies a delta response from the LSP to our storage. + private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback?, textView: TextView?) async { + let lspRanges = storage.applyDelta(data) + lastEditCallback = nil // Don't use this callback again. + await MainActor.run { + let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) } + callback?(.success(IndexSet(ranges: ranges))) + } + } + + private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback?) async { + storage.setData(data) + lastEditCallback = nil // Don't use this callback again. + await callback?(.success(IndexSet(integersIn: documentRange))) + } + + // MARK: - Highlight Provider Conformance + + func setUp(textView: TextView, codeLanguage: CodeLanguage) { + // Send off a request to get the initial token data + self.textView = textView + Task { + try await self.documentDidChange() + } + } + + func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) { + if let lastEditCallback { + lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error + } + lastEditCallback = completion + } + + func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping HighlightCallback) { + guard storage.hasReceivedData else { + pendingHighlightCallbacks.append(completion) + return + } + + guard let lspRange = textView.lspRangeFrom(nsRange: range) else { + completion(.failure(HighlightError.lspRangeFailure)) + return + } + let rawTokens = storage.getTokensFor(range: lspRange) + let highlights = tokenMap + .decode(tokens: rawTokens, using: textView) + .filter({ $0.capture != nil || !$0.modifiers.isEmpty }) + completion(.success(highlights)) + } +} diff --git a/CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift similarity index 80% rename from CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift rename to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift index 5a196cf60..317068a2d 100644 --- a/CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift @@ -45,20 +45,31 @@ struct SemanticTokenMap: Sendable { // swiftlint:enable line_length /// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor. /// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`. /// - Parameters: - /// - tokens: Semantic tokens from a language server. + /// - tokens: Encoded semantic tokens type from a language server. /// - rangeProvider: The provider to use to translate token ranges to text view ranges. /// - Returns: An array of decoded highlight ranges. @MainActor func decode(tokens: SemanticTokens, using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] { - tokens.decode().compactMap { token in + return decode(tokens: tokens.decode(), using: rangeProvider) + } + + /// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor. + /// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`. + /// - Parameters: + /// - tokens: Decoded semantic tokens from a language server. + /// - rangeProvider: The provider to use to translate token ranges to text view ranges. + /// - Returns: An array of decoded highlight ranges. + @MainActor + func decode(tokens: [SemanticToken], using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] { + tokens.compactMap { token in guard let range = rangeProvider.nsRangeFrom(line: token.line, char: token.char, length: token.length) else { return nil } + // Only modifiers are bit packed, capture types are given as a simple index into the ``tokenTypeMap`` let modifiers = decodeModifier(token.modifiers) - // Capture types are indicated by the index of the set bit. - let type = token.type > 0 ? Int(token.type.trailingZeroBitCount) : -1 // Don't try to decode 0 + let type = Int(token.type) let capture = tokenTypeMap.indices.contains(type) ? tokenTypeMap[type] : nil return HighlightRange( diff --git a/CodeEdit/Features/LSP/Editor/SemanticTokenMapRangeProvider.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift similarity index 100% rename from CodeEdit/Features/LSP/Editor/SemanticTokenMapRangeProvider.swift rename to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift new file mode 100644 index 000000000..ecfcb3932 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift @@ -0,0 +1,25 @@ +// +// GenericSemanticTokenStorage.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor + +/// Defines a protocol for an object to provide storage for semantic tokens. +/// +/// There is only one concrete type that conforms to this in CE, but this protocol is useful in testing. +/// See ``SemanticTokenStorage``. +protocol GenericSemanticTokenStorage: AnyObject { + var lastResultId: String? { get } + var hasReceivedData: Bool { get } + + init() + + func getTokensFor(range: LSPRange) -> [SemanticToken] + func setData(_ data: borrowing SemanticTokens) + func applyDelta(_ deltas: SemanticTokensDelta) -> [SemanticTokenRange] +} diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift new file mode 100644 index 000000000..6a7bfff6d --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift @@ -0,0 +1,13 @@ +// +// SemanticTokenRange.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +/// Represents the range of a semantic token. +struct SemanticTokenRange { + let line: UInt32 + let char: UInt32 + let length: UInt32 +} diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift new file mode 100644 index 000000000..3faeae250 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift @@ -0,0 +1,180 @@ +// +// SemanticTokenStorage.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor + +/// This class provides storage for semantic token data. +/// +/// The LSP spec requires that clients keep the original compressed data to apply delta edits. Delta updates may +/// appear as a delta to a single number in the compressed array. This class maintains the current state of compressed +/// tokens and their decoded counterparts. It supports applying delta updates from the language server. +/// +/// See ``SemanticTokenHighlightProvider`` for it's connection to the editor view. +final class SemanticTokenStorage: GenericSemanticTokenStorage { + /// Represents compressed semantic token data received from a language server. + struct CurrentState { + let resultId: String? + let tokenData: [UInt32] + let tokens: [SemanticToken] + } + + /// The last received result identifier. + var lastResultId: String? { + state?.resultId + } + + /// Indicates if the storage object has received any data. + /// Once `setData` has been called, this returns `true`. + /// Other operations will fail without any data in the storage object. + var hasReceivedData: Bool { + state != nil + } + + var state: CurrentState? + + /// Create an empty storage object. + init() { + state = nil + } + + // MARK: - Storage Conformance + + /// Finds all tokens in the given range. + /// - Parameter range: The range to query. + /// - Returns: All tokens found in the range. + func getTokensFor(range: LSPRange) -> [SemanticToken] { + guard let state = state, !state.tokens.isEmpty else { + return [] + } + var tokens: [SemanticToken] = [] + + // Perform a binary search + guard var idx = findLowerBound(in: range, data: state.tokens[...]) else { + return [] + } + + while idx < state.tokens.count && state.tokens[idx].startPosition < range.end { + tokens.append(state.tokens[idx]) + idx += 1 + } + + return tokens + } + + /// Clear the current state and set a new one. + /// - Parameter data: The semantic tokens to set as the current state. + func setData(_ data: borrowing SemanticTokens) { + state = CurrentState(resultId: data.resultId, tokenData: data.data, tokens: data.decode()) + } + + /// Apply a delta object from a language server and returns all token ranges that may need re-drawing. + /// + /// To calculate invalidated ranges: + /// - Grabs all semantic tokens that *will* be updated and invalidates their ranges + /// - Loops over all inserted tokens and invalidates their ranges + /// This may result in duplicated ranges. It's up to the caller to de-duplicate if necessary. See + /// ``SemanticTokenStorage/invalidatedRanges(startIdx:length:data:)``. + /// + /// - Parameter deltas: The deltas to apply. + /// - Returns: Ranges invalidated by the applied deltas. + func applyDelta(_ deltas: SemanticTokensDelta) -> [SemanticTokenRange] { + assert(state != nil, "State should be set before applying any deltas.") + guard var tokenData = state?.tokenData else { return [] } + var invalidatedSet: [SemanticTokenRange] = [] + + // Apply in reverse order (end to start) + for edit in deltas.edits.sorted(by: { $0.start > $1.start }) { + invalidatedSet.append( + contentsOf: invalidatedRanges(startIdx: edit.start, length: edit.deleteCount, data: tokenData[...]) + ) + + // Apply to our copy of the tokens array + if edit.deleteCount > 0 { + tokenData.replaceSubrange(Int(edit.start)..) -> [SemanticTokenRange] { + var ranges: [SemanticTokenRange] = [] + var idx = startIdx - (startIdx % 5) + while idx < startIdx + length { + ranges.append( + SemanticTokenRange( + line: data[Int(idx)], + char: data[Int(idx + 1)], + length: data[Int(idx + 2)] + ) + ) + idx += 5 + } + return ranges + } + + // MARK: - Binary Search + + /// Finds the lowest index of a `SemanticToken` that is entirely within the specified range. + /// - Complexity: Runs an **O(log n)** binary search on the data array. + /// - Parameters: + /// - range: The range to search in, *not* inclusive. + /// - data: The tokens to search. Takes an array slice to avoid unnecessary copying. This must be ordered by + /// `startPosition`. + /// - Returns: The index in the data array of the lowest data element that lies within the given range, or `nil` + /// if none are found. + func findLowerBound(in range: LSPRange, data: ArraySlice) -> Int? { + var low = 0 + var high = data.count + + // Find the first token with startPosition >= range.start. + while low < high { + let mid = low + (high - low) / 2 + if data[mid].startPosition < range.start { + low = mid + 1 + } else { + high = mid + } + } + + // Return the item at `low` if it's valid. + if low < data.count && data[low].startPosition >= range.start && data[low].endPosition < range.end { + return low + } + + return nil + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index 563604aa7..be69c6647 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -12,7 +12,7 @@ extension LanguageServer { /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. /// - Throws: Throws errors produced by the language server connection. - func openDocument(_ document: CodeFileDocument) async throws { + func openDocument(_ document: DocumentType) async throws { do { guard resolveOpenCloseSupport(), let content = await getIsolatedDocumentContent(document) else { return @@ -29,7 +29,7 @@ extension LanguageServer { ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) - await updateIsolatedDocument(document, coordinator: openFiles.contentCoordinator(for: document)) + await updateIsolatedDocument(document) } catch { logger.warning("addDocument: Error \(error)") throw error @@ -41,9 +41,12 @@ extension LanguageServer { /// - Throws: Throws errors produced by the language server connection. func closeDocument(_ uri: String) async throws { do { - guard resolveOpenCloseSupport() && openFiles.document(for: uri) != nil else { return } + guard resolveOpenCloseSupport(), let document = openFiles.document(for: uri) else { return } logger.debug("Closing document \(uri, privacy: .private)") + openFiles.removeDocument(for: uri) + await clearIsolatedDocument(document) + let params = DidCloseTextDocumentParams(textDocument: TextDocumentIdentifier(uri: uri)) try await lspInstance.textDocumentDidClose(params) } catch { @@ -78,10 +81,11 @@ extension LanguageServer { func documentChanged(uri: String, changes: [DocumentChange]) async throws { do { logger.debug("Document updated, \(uri, privacy: .private)") + guard let document = openFiles.document(for: uri) else { return } + switch resolveDocumentSyncKind() { case .full: - guard let document = openFiles.document(for: uri), - let content = await getIsolatedDocumentContent(document) else { + guard let content = await getIsolatedDocumentContent(document) else { return } let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content.string) @@ -100,6 +104,10 @@ extension LanguageServer { case .none: return } + + // Let the semantic token provider know about the update. + // Note for future: If a related LSP object need notifying about document changes, do it here. + try await document.languageServerObjects.highlightProvider?.documentDidChange() } catch { logger.warning("closeDocument: Error \(error)") throw error @@ -110,18 +118,25 @@ extension LanguageServer { /// Helper function for grabbing a document's content from the main actor. @MainActor - private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + private func getIsolatedDocumentContent(_ document: DocumentType) -> DocumentContent? { guard let uri = document.languageServerURI, - let language = document.getLanguage().lspLanguage, let content = document.content?.string else { return nil } - return DocumentContent(uri: uri, language: language, string: content) + return DocumentContent(uri: uri, language: document.getLanguage().id.rawValue, string: content) + } + + @MainActor + private func updateIsolatedDocument(_ document: DocumentType) { + document.languageServerObjects = LanguageServerDocumentObjects( + textCoordinator: openFiles.contentCoordinator(for: document), + highlightProvider: openFiles.semanticHighlighter(for: document) + ) } @MainActor - private func updateIsolatedDocument(_ document: CodeFileDocument, coordinator: LSPContentCoordinator?) { - document.lspCoordinator = coordinator + private func clearIsolatedDocument(_ document: DocumentType) { + document.languageServerObjects = LanguageServerDocumentObjects() } // swiftlint:disable line_length @@ -156,7 +171,7 @@ extension LanguageServer { // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` fileprivate struct DocumentContent { let uri: String - let language: LanguageIdentifier + let language: String let string: String } } diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift index 02cb29947..b95098d02 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift @@ -9,12 +9,9 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - /// Setup and test the validity of a rename operation at a given location func requestSemanticTokens(for documentURI: String) async throws -> SemanticTokensResponse { do { - let params = SemanticTokensParams( - textDocument: TextDocumentIdentifier(uri: documentURI) - ) + let params = SemanticTokensParams(textDocument: TextDocumentIdentifier(uri: documentURI)) return try await lspInstance.semanticTokensFull(params) } catch { logger.warning("requestSemanticTokens full: Error \(error)") @@ -22,19 +19,6 @@ extension LanguageServer { } } - func requestSemanticTokens( - for documentURI: String, - forRange range: LSPRange - ) async throws -> SemanticTokensResponse { - do { - let params = SemanticTokensRangeParams(textDocument: TextDocumentIdentifier(uri: documentURI), range: range) - return try await lspInstance.semanticTokensRange(params) - } catch { - logger.warning("requestSemanticTokens range: Error \(error)") - throw error - } - } - func requestSemanticTokens( for documentURI: String, previousResultId: String diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index eab8be550..a7c48bb25 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -11,8 +11,11 @@ import LanguageClient import LanguageServerProtocol import OSLog -class LanguageServer { - static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") +/// A client for language servers. +class LanguageServer { + static var logger: Logger { // types with associated types cannot have constant static properties + Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") + } let logger: Logger /// Identifies which language the server belongs to @@ -25,7 +28,7 @@ class LanguageServer { /// Tracks documents and their associated objects. /// Use this property when adding new objects that need to track file data, or have a state associated with the /// language server and a document. For example, the content coordinator. - let openFiles: LanguageServerFileMap + let openFiles: LanguageServerFileMap /// Maps the language server's highlight config to one CodeEdit can read. See ``SemanticTokenMap``. let highlightMap: SemanticTokenMap? @@ -152,13 +155,13 @@ class LanguageServer { // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokensClientCapabilities semanticTokens: SemanticTokensClientCapabilities( dynamicRegistration: false, - requests: .init(range: true, delta: true), + requests: .init(range: false, delta: true), tokenTypes: SemanticTokenTypes.allStrings, tokenModifiers: SemanticTokenModifiers.allStrings, formats: [.relative], overlappingTokenSupport: true, multilineTokenSupport: true, - serverCancelSupport: true, + serverCancelSupport: false, augmentsSyntaxTokens: true ) ) @@ -218,7 +221,7 @@ class LanguageServer { processId: nil, locale: nil, rootPath: nil, - rootUri: workspacePath, + rootUri: "file://" + workspacePath, // Make it a URI initializationOptions: [], capabilities: capabilities, trace: nil, diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index c681e894a..fd71a06b7 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -9,39 +9,55 @@ import Foundation import LanguageServerProtocol /// Tracks data associated with files and language servers. -class LanguageServerFileMap { +class LanguageServerFileMap { + typealias HighlightProviderType = SemanticTokenHighlightProvider + /// Extend this struct as more objects are associated with a code document. private struct DocumentObject { let uri: String var documentVersion: Int - var contentCoordinator: LSPContentCoordinator + var contentCoordinator: LSPContentCoordinator + var semanticHighlighter: HighlightProviderType? } - private var trackedDocuments: NSMapTable + private var trackedDocuments: NSMapTable private var trackedDocumentData: [String: DocumentObject] = [:] init() { - trackedDocuments = NSMapTable(valueOptions: [.weakMemory]) + trackedDocuments = NSMapTable(valueOptions: [.weakMemory]) } // MARK: - Track & Remove Documents - func addDocument(_ document: CodeFileDocument, for server: LanguageServer) { + func addDocument(_ document: DocumentType, for server: LanguageServer) { guard let uri = document.languageServerURI else { return } trackedDocuments.setObject(document, forKey: uri as NSString) - trackedDocumentData[uri] = DocumentObject( + var docData = DocumentObject( uri: uri, documentVersion: 0, - contentCoordinator: LSPContentCoordinator(documentURI: uri, languageServer: server) + contentCoordinator: LSPContentCoordinator( + documentURI: uri, + languageServer: server + ), + semanticHighlighter: nil ) + + if let tokenMap = server.highlightMap { + docData.semanticHighlighter = HighlightProviderType( + tokenMap: tokenMap, + languageServer: server, + documentURI: uri + ) + } + + trackedDocumentData[uri] = docData } - func document(for uri: DocumentUri) -> CodeFileDocument? { - let url = URL(filePath: uri) - return trackedDocuments.object(forKey: url.absolutePath as NSString) + func document(for uri: DocumentUri) -> DocumentType? { + return trackedDocuments.object(forKey: uri as NSString) } - func removeDocument(for document: CodeFileDocument) { + func removeDocument(for document: DocumentType) { guard let uri = document.languageServerURI else { return } removeDocument(for: uri) } @@ -53,7 +69,7 @@ class LanguageServerFileMap { // MARK: - Version Number Tracking - func incrementVersion(for document: CodeFileDocument) -> Int { + func incrementVersion(for document: DocumentType) -> Int { guard let uri = document.languageServerURI else { return 0 } return incrementVersion(for: uri) } @@ -63,7 +79,7 @@ class LanguageServerFileMap { return trackedDocumentData[uri]?.documentVersion ?? 0 } - func documentVersion(for document: CodeFileDocument) -> Int? { + func documentVersion(for document: DocumentType) -> Int? { guard let uri = document.languageServerURI else { return nil } return documentVersion(for: uri) } @@ -74,12 +90,19 @@ class LanguageServerFileMap { // MARK: - Content Coordinator - func contentCoordinator(for document: CodeFileDocument) -> LSPContentCoordinator? { + func contentCoordinator(for document: DocumentType) -> LSPContentCoordinator? { guard let uri = document.languageServerURI else { return nil } return contentCoordinator(for: uri) } - func contentCoordinator(for uri: DocumentUri) -> LSPContentCoordinator? { + func contentCoordinator(for uri: DocumentUri) -> LSPContentCoordinator? { trackedDocumentData[uri]?.contentCoordinator } + + // MARK: - Semantic Highlighter + + func semanticHighlighter(for document: DocumentType) -> HighlightProviderType? { + guard let uri = document.languageServerURI else { return nil } + return trackedDocumentData[uri]?.semanticHighlighter + } } diff --git a/CodeEdit/Features/LSP/LanguageServerDocument.swift b/CodeEdit/Features/LSP/LanguageServerDocument.swift new file mode 100644 index 000000000..8b4b09a47 --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServerDocument.swift @@ -0,0 +1,23 @@ +// +// LanguageServerDocument.swift +// CodeEdit +// +// Created by Khan Winter on 2/12/25. +// + +import AppKit +import CodeEditLanguages + +/// A set of properties a language server sets when a document is registered. +struct LanguageServerDocumentObjects { + var textCoordinator: LSPContentCoordinator? + var highlightProvider: SemanticTokenHighlightProvider? +} + +/// A protocol that allows a language server to register objects on a text document. +protocol LanguageServerDocument: AnyObject { + var content: NSTextStorage? { get } + var languageServerURI: String? { get } + var languageServerObjects: LanguageServerDocumentObjects { get set } + func getLanguage() -> CodeLanguage +} diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index eedee22a9..b7d8a2087 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -43,7 +43,7 @@ import SwiftUICore /// do { /// guard var languageClient = self.languageClient(for: .python) else { /// print("Failed to get client") -/// throw ServerManagerError.languageClientNotFound +/// throw LSPServiceError.languageClientNotFound /// } /// /// let testFilePathStr = "" @@ -55,7 +55,7 @@ import SwiftUICore /// // Completion example /// let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 /// let completions = try await languageClient.requestCompletion( -/// document: testFileURL.absoluteString, +/// document: testFileURL.lspURI, /// position: textPosition /// ) /// switch completions { @@ -100,6 +100,8 @@ import SwiftUICore /// ``` @MainActor final class LSPService: ObservableObject { + typealias LanguageServerType = LanguageServer + let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") struct ClientKey: Hashable, Equatable { @@ -113,7 +115,7 @@ final class LSPService: ObservableObject { } /// Holds the active language clients - var languageClients: [ClientKey: LanguageServer] = [:] + var languageClients: [ClientKey: LanguageServerType] = [:] /// Holds the language server configurations for all the installed language servers var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] /// Holds all the event listeners for each active language client @@ -166,10 +168,16 @@ final class LSPService: ObservableObject { } /// Gets the language client for the specified language - func languageClient(for languageId: LanguageIdentifier, workspacePath: String) -> LanguageServer? { + func languageClient(for languageId: LanguageIdentifier, workspacePath: String) -> LanguageServerType? { return languageClients[ClientKey(languageId, workspacePath)] } + func languageClient(forDocument url: URL) -> LanguageServerType? { + languageClients.values.first(where: { $0.openFiles.document(for: url.lspURI) != nil }) + } + + // MARK: - Start Server + /// Given a language and workspace path, will attempt to start the language server /// - Parameters: /// - languageId: The ID of the language server to start. @@ -178,14 +186,14 @@ final class LSPService: ObservableObject { func startServer( for languageId: LanguageIdentifier, workspacePath: String - ) async throws -> LanguageServer { + ) async throws -> LanguageServerType { guard let serverBinary = languageConfigs[languageId] else { logger.error("Couldn't find language sever binary for \(languageId.rawValue)") throw LSPError.binaryNotFound } logger.info("Starting \(languageId.rawValue) language server") - let server = try await LanguageServer.createServer( + let server = try await LanguageServerType.createServer( for: languageId, with: serverBinary, workspacePath: workspacePath @@ -197,6 +205,8 @@ final class LSPService: ObservableObject { return server } + // MARK: - Document Management + /// Notify all relevant language clients that a document was opened. /// - Note: Must be invoked after the contents of the file are available. /// - Parameter document: The code document that was opened. @@ -207,7 +217,7 @@ final class LSPService: ObservableObject { return } Task { - let languageServer: LanguageServer + let languageServer: LanguageServerType do { if let server = self.languageClients[ClientKey(lspLanguage, workspacePath)] { languageServer = server @@ -233,21 +243,19 @@ final class LSPService: ObservableObject { /// Notify all relevant language clients that a document was closed. /// - Parameter url: The url of the document that was closed func closeDocument(_ url: URL) { - guard let languageClient = languageClients.first(where: { - $0.value.openFiles.document(for: url.absolutePath) != nil - })?.value else { - return - } + guard let languageClient = languageClient(forDocument: url) else { return } Task { do { - try await languageClient.closeDocument(url.absolutePath) + try await languageClient.closeDocument(url.lspURI) } catch { // swiftlint:disable:next line_length - logger.error("Failed to close document: \(url.absolutePath, privacy: .private), language: \(languageClient.languageId.rawValue). Error \(error)") + logger.error("Failed to close document: \(url.lspURI, privacy: .private), language: \(languageClient.languageId.rawValue). Error \(error)") } } } + // MARK: - Close Workspace + /// Close all language clients for a workspace. /// /// This is intentionally synchronous so we can exit from the workspace document's ``WorkspaceDocument/close()`` @@ -271,6 +279,8 @@ final class LSPService: ObservableObject { } } + // MARK: - Stop Servers + /// Attempts to stop a running language server. Throws an error if the server is not found /// or if the language server throws an error while trying to shutdown. /// - Parameters: @@ -279,7 +289,7 @@ final class LSPService: ObservableObject { func stopServer(forLanguage languageId: LanguageIdentifier, workspacePath: String) async throws { guard let server = server(for: languageId, workspacePath: workspacePath) else { logger.error("Server not found for language \(languageId.rawValue) during stop operation") - throw ServerManagerError.serverNotFound + throw LSPServiceError.serverNotFound } do { try await server.shutdownAndExit() diff --git a/CodeEdit/Features/LSP/Service/LSPServiceError.swift b/CodeEdit/Features/LSP/Service/LSPServiceError.swift new file mode 100644 index 000000000..d542e4d75 --- /dev/null +++ b/CodeEdit/Features/LSP/Service/LSPServiceError.swift @@ -0,0 +1,13 @@ +// +// LSPServiceError.swift +// CodeEdit +// +// Created by Khan Winter on 3/24/25. +// + +enum LSPServiceError: Error { + case serverNotFound + case serverStartFailed + case serverStopFailed + case languageClientNotFound +} diff --git a/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift b/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift new file mode 100644 index 000000000..0e700938a --- /dev/null +++ b/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift @@ -0,0 +1,18 @@ +// +// SemanticToken+Position.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import LanguageServerProtocol + +extension SemanticToken { + var startPosition: Position { + Position(line: Int(line), character: Int(char)) + } + + var endPosition: Position { + Position(line: Int(line), character: Int(char + length)) + } +} diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift b/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift index f41060423..976f9970f 100644 --- a/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift +++ b/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift @@ -7,8 +7,13 @@ import Foundation import CodeEditTextView +import LanguageServerProtocol extension TextView: SemanticTokenMapRangeProvider { + func nsRangeFrom(_ range: SemanticTokenRange) -> NSRange? { + nsRangeFrom(line: range.line, char: range.char, length: range.length) + } + func nsRangeFrom(line: UInt32, char: UInt32, length: UInt32) -> NSRange? { guard let line = layoutManager.textLineForIndex(Int(line)) else { return nil diff --git a/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift b/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift new file mode 100644 index 000000000..f29f9057c --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift @@ -0,0 +1,18 @@ +// +// URL+LSPURI.swift +// CodeEdit +// +// Created by Khan Winter on 3/24/25. +// + +import Foundation + +extension URL { + /// A stable string to use when identifying documents with language servers. + /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. + /// + /// Use this whenever possible when using USLs in LSP processing if not using the ``LanguageServerDocument`` type. + var lspURI: String { + return "file://" + absolutePath + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift similarity index 80% rename from CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift rename to CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index d5bee0c13..236f2a721 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -1,5 +1,5 @@ // -// LanguageServer+DocumentTests.swift +// LanguageServer+CodeFileDocument.swift // CodeEditTests // // Created by Khan Winter on 9/9/24. @@ -13,10 +13,16 @@ import LanguageServerProtocol @testable import CodeEdit -final class LanguageServerDocumentTests: XCTestCase { +/// This is an integration test for notifications relating to the ``CodeFileDocument`` class. +/// +/// For *unit* tests with the language server class, add tests to the `LanguageServer+DocumentObjects` test class as +/// it's cleaner and makes correct use of the mock document type. +final class LanguageServerCodeFileDocumentTests: XCTestCase { // Test opening documents in CodeEdit triggers creating a language server, // further opened documents don't create new servers + typealias LanguageServerType = LanguageServer + var tempTestDir: URL! override func setUp() { @@ -44,7 +50,7 @@ final class LanguageServerDocumentTests: XCTestCase { } } - func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServer) { + func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServerType) { let bufferingConnection = BufferingServerConnection() var capabilities = ServerCapabilities() capabilities.textDocumentSync = .optionA( @@ -56,12 +62,12 @@ final class LanguageServerDocumentTests: XCTestCase { save: nil ) ) - let server = LanguageServer( + let server = LanguageServerType( languageId: .swift, binary: .init(execPath: "", args: [], env: nil), lspInstance: InitializingServer( server: bufferingConnection, - initializeParamsProvider: LanguageServer.getInitParams(workspacePath: tempTestDir.path()) + initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: tempTestDir.path()) ), serverCapabilities: capabilities, rootPath: tempTestDir @@ -81,7 +87,7 @@ final class LanguageServerDocumentTests: XCTestCase { } func openCodeFile( - for server: LanguageServer, + for server: LanguageServerType, connection: BufferingServerConnection, file: CEWorkspaceFile, syncOption: TwoTypeOption? @@ -95,8 +101,11 @@ final class LanguageServerDocumentTests: XCTestCase { // This is usually sent from the LSPService try await server.openDocument(codeFile) - await waitForClientEventCount( - 3, + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen] + ), connection: connection, description: "Initialized (2) and opened (1) notification count" ) @@ -108,15 +117,18 @@ final class LanguageServerDocumentTests: XCTestCase { return codeFile } - func waitForClientEventCount(_ count: Int, connection: BufferingServerConnection, description: String) async { + func waitForClientState( + _ expectedValue: ([ClientRequest.Method], [ClientNotification.Method]), + connection: BufferingServerConnection, + description: String + ) async { let expectation = expectation(description: description) await withTaskGroup(of: Void.self) { group in + group.addTask { await self.fulfillment(of: [expectation], timeout: 2) } group.addTask { - await self.fulfillment(of: [expectation], timeout: 2) - } - group.addTask { - for await events in connection.clientEventSequence where events.0.count + events.1.count == count { + for await events in connection.clientEventSequence + where events.0.map(\.method) == expectedValue.0 && events.1.map(\.method) == expectedValue.1 { expectation.fulfill() return } @@ -124,6 +136,8 @@ final class LanguageServerDocumentTests: XCTestCase { } } + // MARK: - Open Close + @MainActor func testOpenCloseFileNotifications() async throws { // Set up test server @@ -153,30 +167,30 @@ final class LanguageServerDocumentTests: XCTestCase { file.fileDocument = codeFile CodeEditDocumentController.shared.addDocument(codeFile) - await waitForClientEventCount(3, connection: connection, description: "Pre-close event count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen] + ), + connection: connection, + description: "Pre-close event count" + ) // This should then trigger a documentDidClose event codeFile.close() - await waitForClientEventCount(4, connection: connection, description: "Post-close event count") - - XCTAssertEqual( - connection.clientRequests.map { $0.method }, - [ - ClientRequest.Method.initialize, - ] - ) - - XCTAssertEqual( - connection.clientNotifications.map { $0.method }, - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidClose - ] + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidClose] + ), + connection: connection, + description: "Post-close event count" ) } + // MARK: - Test Document Edit + /// Assert the changed contents received by the buffered connection func assertExpectedContentChanges(connection: BufferingServerConnection, changes: [String]) { var foundChangeContents: [String] = [] @@ -184,9 +198,7 @@ final class LanguageServerDocumentTests: XCTestCase { for notification in connection.clientNotifications { switch notification { case let .textDocumentDidChange(params): - foundChangeContents.append(contentsOf: params.contentChanges.map { event in - event.text - }) + foundChangeContents.append(contentsOf: params.contentChanges.map(\.text)) default: continue } @@ -231,18 +243,17 @@ final class LanguageServerDocumentTests: XCTestCase { textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") // Added one notification - await waitForClientEventCount(4, connection: connection, description: "Edited notification count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidChange] + ), + connection: connection, + description: "Edited notification count" + ) // Make sure our text view is intact XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) - XCTAssertEqual( - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidChange - ], - connection.clientNotifications.map { $0.method } - ) // Expect only one change due to throttling. assertExpectedContentChanges( @@ -289,18 +300,17 @@ final class LanguageServerDocumentTests: XCTestCase { textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") // Throttling means we should receive one edited notification + init notification + didOpen + init request - await waitForClientEventCount(4, connection: connection, description: "Edited notification count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidChange] + ), + connection: connection, + description: "Edited notification count" + ) // Make sure our text view is intact XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) - XCTAssertEqual( - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidChange - ], - connection.clientNotifications.map { $0.method } - ) // Expect three content changes. assertExpectedContentChanges( diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift new file mode 100644 index 000000000..76b2e8cf3 --- /dev/null +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -0,0 +1,80 @@ +// +// LanguageServer+DocumentObjects.swift +// CodeEditTests +// +// Created by Khan Winter on 2/12/25. +// + +import XCTest +import CodeEditTextView +import CodeEditSourceEditor +import CodeEditLanguages +import LanguageClient +import LanguageServerProtocol + +@testable import CodeEdit + +final class LanguageServerDocumentObjectsTests: XCTestCase { + final class MockDocumentType: LanguageServerDocument { + var content: NSTextStorage? + var languageServerURI: String? + var languageServerObjects: LanguageServerDocumentObjects + + init() { + self.content = NSTextStorage(string: "hello world") + self.languageServerURI = "/test/file/path" + self.languageServerObjects = .init() + } + + func getLanguage() -> CodeLanguage { + .swift + } + } + + typealias LanguageServerType = LanguageServer + + var document: MockDocumentType! + var server: LanguageServerType! + + // MARK: - Set Up + + override func setUp() async throws { + var capabilities = ServerCapabilities() + capabilities.textDocumentSync = .optionA(.init(openClose: true, change: .full)) + capabilities.semanticTokensProvider = .optionA(.init(legend: .init(tokenTypes: [], tokenModifiers: []))) + server = LanguageServerType( + languageId: .swift, + binary: .init(execPath: "", args: [], env: nil), + lspInstance: InitializingServer( + server: BufferingServerConnection(), + initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: "/") + ), + serverCapabilities: capabilities, + rootPath: URL(fileURLWithPath: "") + ) + _ = try await server.lspInstance.initializeIfNeeded() + document = MockDocumentType() + } + + // MARK: - Tests + + func testOpenDocumentRegistersObjects() async throws { + try await server.openDocument(document) + XCTAssertNotNil(document.languageServerObjects.highlightProvider) + XCTAssertNotNil(document.languageServerObjects.textCoordinator) + XCTAssertNotNil(server.openFiles.document(for: document.languageServerURI ?? "")) + } + + func testCloseDocumentClearsObjects() async throws { + guard let languageServerURI = document.languageServerURI else { + XCTFail("Language server URI missing on a mock object") + return + } + try await server.openDocument(document) + XCTAssertNotNil(server.openFiles.document(for: languageServerURI)) + + try await server.closeDocument(languageServerURI) + XCTAssertNil(document.languageServerObjects.highlightProvider) + XCTAssertNil(document.languageServerObjects.textCoordinator) + } +} diff --git a/CodeEditTests/Features/LSP/SemanticTokenMapTests.swift b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift similarity index 79% rename from CodeEditTests/Features/LSP/SemanticTokenMapTests.swift rename to CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift index 4c941de1a..a9ec5c5a3 100644 --- a/CodeEditTests/Features/LSP/SemanticTokenMapTests.swift +++ b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift @@ -10,7 +10,7 @@ import CodeEditSourceEditor import LanguageServerProtocol @testable import CodeEdit -final class SemanticTokenMapTestsTests: XCTestCase { +final class SemanticTokenMapTests: XCTestCase { // Ignores the line parameter and just returns a range from the char and length for testing struct MockRangeProvider: SemanticTokenMapRangeProvider { func nsRangeFrom(line: UInt32, char: UInt32, length: UInt32) -> NSRange? { @@ -53,10 +53,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { // Test decode tokens let tokens = SemanticTokens(tokens: [ - SemanticToken(line: 0, char: 0, length: 1, type: 0, modifiers: 0b11), // First two indices set + SemanticToken(line: 0, char: 0, length: 1, type: 1000000, modifiers: 0b11), // First two indices set SemanticToken(line: 0, char: 1, length: 2, type: 0, modifiers: 0b100100), // 6th and 3rd indices set - SemanticToken(line: 0, char: 4, length: 1, type: 0b1, modifiers: 0b101), - SemanticToken(line: 0, char: 5, length: 1, type: 0b100, modifiers: 0b1010), + SemanticToken(line: 0, char: 4, length: 1, type: 1, modifiers: 0b101), + SemanticToken(line: 0, char: 5, length: 1, type: 4, modifiers: 0b1010), SemanticToken(line: 0, char: 7, length: 10, type: 0, modifiers: 0) ]) let decoded = map.decode(tokens: tokens, using: mockProvider) @@ -69,10 +69,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { XCTAssertEqual(decoded[4].range, NSRange(location: 7, length: 10), "Decoded range") XCTAssertEqual(decoded[0].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[1].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[2].capture, .include, "Decoded Capture") - XCTAssertEqual(decoded[3].capture, .keyword, "Decoded Capture") - XCTAssertEqual(decoded[4].capture, nil, "No Decoded Capture") + XCTAssertEqual(decoded[1].capture, .include, "No Decoded Capture") + XCTAssertEqual(decoded[2].capture, .constructor, "Decoded Capture") + XCTAssertEqual(decoded[3].capture, .comment, "Decoded Capture") + XCTAssertEqual(decoded[4].capture, .include, "No Decoded Capture") XCTAssertEqual(decoded[0].modifiers, [.declaration, .definition], "Decoded Modifiers") XCTAssertEqual(decoded[1].modifiers, [.readonly, .defaultLibrary], "Decoded Modifiers") @@ -92,10 +92,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { // Test decode tokens let tokens = SemanticTokens(tokens: [ - SemanticToken(line: 0, char: 0, length: 1, type: 0, modifiers: 0b11), // First two indices set + SemanticToken(line: 0, char: 0, length: 1, type: 100, modifiers: 0b11), // First two indices set SemanticToken(line: 0, char: 1, length: 2, type: 0, modifiers: 0b100100), // 6th and 3rd indices set - SemanticToken(line: 0, char: 4, length: 1, type: 0b1, modifiers: 0b101), - SemanticToken(line: 0, char: 5, length: 1, type: 0b100, modifiers: 0b1010), + SemanticToken(line: 0, char: 4, length: 1, type: 1, modifiers: 0b101), + SemanticToken(line: 0, char: 5, length: 1, type: 4, modifiers: 0b1010), SemanticToken(line: 0, char: 7, length: 10, type: 0, modifiers: 0) ]) let decoded = map.decode(tokens: tokens, using: mockProvider) @@ -108,10 +108,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { XCTAssertEqual(decoded[4].range, NSRange(location: 7, length: 10), "Decoded range") XCTAssertEqual(decoded[0].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[1].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[2].capture, .include, "Decoded Capture") - XCTAssertEqual(decoded[3].capture, .keyword, "Decoded Capture") - XCTAssertEqual(decoded[4].capture, nil, "No Decoded Capture") + XCTAssertEqual(decoded[1].capture, .include, "No Decoded Capture") + XCTAssertEqual(decoded[2].capture, .constructor, "Decoded Capture") + XCTAssertEqual(decoded[3].capture, .comment, "Decoded Capture") + XCTAssertEqual(decoded[4].capture, .include, "No Decoded Capture") XCTAssertEqual(decoded[0].modifiers, [.declaration, .definition], "Decoded Modifiers") XCTAssertEqual(decoded[1].modifiers, [.readonly, .defaultLibrary], "Decoded Modifiers") diff --git a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift new file mode 100644 index 000000000..f2d0179ca --- /dev/null +++ b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift @@ -0,0 +1,199 @@ +// +// SemanticTokenStorageTests.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import Testing +import CodeEditSourceEditor +import LanguageServerProtocol +@testable import CodeEdit + +// For easier comparison while setting semantic tokens +extension SemanticToken: @retroactive Equatable { + public static func == (lhs: SemanticToken, rhs: SemanticToken) -> Bool { + lhs.type == rhs.type + && lhs.modifiers == rhs.modifiers + && lhs.line == rhs.line + && lhs.char == rhs.char + && lhs.length == rhs.length + } +} + +@Suite +struct SemanticTokenStorageTests { + let storage = SemanticTokenStorage() + + let semanticTokens = [ + SemanticToken(line: 0, char: 0, length: 10, type: 0, modifiers: 0), + SemanticToken(line: 1, char: 2, length: 5, type: 2, modifiers: 3), + SemanticToken(line: 3, char: 8, length: 10, type: 1, modifiers: 0) + ] + + @Test + func initialState() async throws { + #expect(storage.state == nil) + #expect(storage.hasReceivedData == false) + #expect(storage.lastResultId == nil) + } + + @Test + func setData() async throws { + storage.setData( + SemanticTokens( + resultId: "1234", + tokens: semanticTokens + ) + ) + + let state = try #require(storage.state) + #expect(state.tokens == semanticTokens) + #expect(state.resultId == "1234") + + #expect(storage.lastResultId == "1234") + #expect(storage.hasReceivedData == true) + } + + @Test + func overwriteDataRepeatedly() async throws { + let dataToApply: [(String?, [SemanticToken])] = [ + (nil, semanticTokens), + ("1", []), + ("2", semanticTokens.dropLast()), + ("3", semanticTokens) + ] + for (resultId, tokens) in dataToApply { + storage.setData(SemanticTokens(resultId: resultId, tokens: tokens)) + let state = try #require(storage.state) + #expect(state.tokens == tokens) + #expect(state.resultId == resultId) + #expect(storage.lastResultId == resultId) + #expect(storage.hasReceivedData == true) + } + } + + @Suite("ApplyDeltas") + struct TokensDeltasTests { + struct DeltaEdit { + let start: Int + let deleteCount: Int + let data: [Int] + + func makeString() -> String { + let dataString = data.map { String($0) }.joined(separator: ",") + return "{\"start\": \(start), \"deleteCount\": \(deleteCount), \"data\": [\(dataString)] }" + } + } + + func makeDelta(resultId: String, edits: [DeltaEdit]) throws -> SemanticTokensDelta { + // This is unfortunate, but there's no public initializer for these structs. + // So we have to decode them from JSON strings + let editsString = edits.map { $0.makeString() }.joined(separator: ",") + let deltasJSON = "{ \"resultId\": \"\(resultId)\", \"edits\": [\(editsString)] }" + let decoder = JSONDecoder() + let deltas = try decoder.decode(SemanticTokensDelta.self, from: Data(deltasJSON.utf8)) + return deltas + } + + let storage: SemanticTokenStorage + + let semanticTokens = [ + SemanticToken(line: 0, char: 0, length: 10, type: 0, modifiers: 0), + SemanticToken(line: 1, char: 2, length: 5, type: 2, modifiers: 3), + SemanticToken(line: 3, char: 8, length: 10, type: 1, modifiers: 0) + ] + + init() { + storage = SemanticTokenStorage() + storage.setData(SemanticTokens(tokens: semanticTokens)) + #expect(storage.state?.tokens == semanticTokens) + } + + @Test + func applyEmptyDeltasNoChange() throws { + let deltas = try makeDelta(resultId: "1", edits: []) + + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 3) + #expect(state.resultId == "1") + #expect(state.tokens == semanticTokens) + } + + @Test + func applyInsertDeltas() throws { + let deltas = try makeDelta(resultId: "1", edits: [.init(start: 0, deleteCount: 0, data: [0, 2, 3, 0, 1])]) + + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 4) + #expect(storage.lastResultId == "1") + + // Should have inserted one at the beginning + #expect(state.tokens[0].line == 0) + #expect(state.tokens[0].char == 2) + #expect(state.tokens[0].length == 3) + #expect(state.tokens[0].modifiers == 1) + + // We inserted a delta into the space before this one (at char 2) so this one starts at the same spot + #expect(state.tokens[1] == SemanticToken(line: 0, char: 2, length: 10, type: 0, modifiers: 0)) + #expect(state.tokens[2] == semanticTokens[1]) + #expect(state.tokens[3] == semanticTokens[2]) + } + + @Test + func applyDeleteOneDeltas() throws { + // Delete the second token (semanticTokens[1]) from the initial state. + // Each token is represented by 5 numbers, so token[1] starts at raw data index 5. + let deltas = try makeDelta(resultId: "2", edits: [.init(start: 5, deleteCount: 5, data: [])]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 2) + #expect(state.resultId == "2") + // The remaining tokens should be the first and third tokens, except we deleted one line between them + // so the third token's line is less one + #expect(state.tokens[0] == semanticTokens[0]) + #expect(state.tokens[1] == SemanticToken(line: 2, char: 8, length: 10, type: 1, modifiers: 0)) + } + + @Test + func applyDeleteManyDeltas() throws { + // Delete the first two tokens from the initial state. + // Token[0] and token[1] together use 10 integers. + let deltas = try makeDelta(resultId: "3", edits: [.init(start: 0, deleteCount: 10, data: [])]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 1) + #expect(state.resultId == "3") + // The only remaining token should be the original third token. + #expect(state.tokens[0] == SemanticToken(line: 2, char: 8, length: 10, type: 1, modifiers: 0)) + } + + @Test + func applyInsertAndDeleteDeltas() throws { + // Combined test: insert a token at the beginning and delete the last token. + // Edit 1: Insert a new token at the beginning. + let insertion = DeltaEdit(start: 0, deleteCount: 0, data: [0, 2, 3, 0, 1]) + // Edit 2: Delete the token that starts at raw data index 10 (the third token in the original state). + let deletion = DeltaEdit(start: 10, deleteCount: 5, data: []) + let deltas = try makeDelta(resultId: "4", edits: [insertion, deletion]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 3) + #expect(storage.lastResultId == "4") + // The new inserted token becomes the first token. + #expect(state.tokens[0] == SemanticToken(line: 0, char: 2, length: 3, type: 0, modifiers: 1)) + // The original first token is shifted (its character offset increased by 2). + #expect(state.tokens[1] == SemanticToken(line: 0, char: 2, length: 10, type: 0, modifiers: 0)) + // The second token from the original state remains unchanged. + #expect(state.tokens[2] == semanticTokens[1]) + } + } +} From 4bd724e6c89097db6e680763e95b96a9b0ea4f1d Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 12 May 2025 14:32:49 -0700 Subject: [PATCH 36/38] Refactors --- CodeEdit.xcodeproj/project.pbxproj | 17 ++-- .../LSP/Registry/InstallationMethod.swift | 54 +++++++++++ .../LSP/Registry/PackageManagerError.swift | 13 +++ .../LSP/Registry/PackageManagerProtocol.swift | 89 +------------------ .../LSP/Registry/PackageManagerType.swift | 30 +++++++ .../PackageManagers/CargoPackageManager.swift | 2 +- .../GithubPackageManager.swift | 2 +- .../GolangPackageManager.swift | 3 +- .../PackageManagers/NPMPackageManager.swift | 2 +- .../PackageManagers/PipPackageManager.swift | 4 +- .../RegistryManager+HandleRegistryFile.swift | 6 +- .../LSP/Registry/RegistryManager.swift | 10 +-- .../LSP/Registry/RegistryManagerError.swift | 15 ++++ .../Features/LSP/Service/LSPService.swift | 2 +- CodeEdit/Features/Settings/SettingsView.swift | 14 +-- 15 files changed, 147 insertions(+), 116 deletions(-) create mode 100644 CodeEdit/Features/LSP/Registry/InstallationMethod.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagerError.swift create mode 100644 CodeEdit/Features/LSP/Registry/PackageManagerType.swift create mode 100644 CodeEdit/Features/LSP/Registry/RegistryManagerError.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index b2fe76d2a..081385cdc 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -424,6 +424,7 @@ 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1661,14 +1662,6 @@ minimumVersion = 0.9.19; }; }; - 30C549D82D77BDF8008DDEF8 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.11.0; - }; - }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ChimeHQ/LanguageServerProtocol"; @@ -1685,6 +1678,14 @@ minimumVersion = 0.8.0; }; }; + 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.19; + }; + }; 583E529A29361BAB001AB554 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; diff --git a/CodeEdit/Features/LSP/Registry/InstallationMethod.swift b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift new file mode 100644 index 000000000..532a92c1b --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift @@ -0,0 +1,54 @@ +// +// InstallationMethod.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +import Foundation + +/// Installation method enum with all supported types +enum InstallationMethod: Equatable { + /// For standard package manager installations + case standardPackage(source: PackageSource) + /// For packages that need to be built from source with custom build steps + case sourceBuild(source: PackageSource, command: String) + /// For direct binary downloads + case binaryDownload(source: PackageSource, url: URL) + /// For installations that aren't recognized + case unknown + + var packageName: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.pkgName + case .unknown: + return nil + } + } + + var version: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.version + case .unknown: + return nil + } + } + + var packageManagerType: PackageManagerType? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.type + case .unknown: + return nil + } + } +} + diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerError.swift b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift new file mode 100644 index 000000000..fd92c2630 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift @@ -0,0 +1,13 @@ +// +// 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 32e7e28e3..1077cff1b 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -23,7 +23,7 @@ protocol PackageManagerProtocol { extension PackageManagerProtocol { /// Creates the directory for the language server to be installed in - internal func createDirectoryStructure(for packagePath: URL) throws { + func createDirectoryStructure(for packagePath: URL) throws { let decodedPath = packagePath.path.removingPercentEncoding ?? packagePath.path if !FileManager.default.fileExists(atPath: decodedPath) { try FileManager.default.createDirectory( @@ -35,12 +35,12 @@ extension PackageManagerProtocol { } /// Executes commands in the specified directory - internal func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { + 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 - internal func runCommand(_ command: String) async throws -> [String] { + func runCommand(_ command: String) async throws -> [String] { var output: [String] = [] for try await line in shellClient.runAsync(command) { output.append(line) @@ -49,44 +49,6 @@ extension PackageManagerProtocol { } } -enum PackageManagerError: Error { - case packageManagerNotInstalled - case initializationFailed(String) - case installationFailed(String) - case invalidConfiguration -} - -enum RegistryManagerError: Error { - case invalidResponse(statusCode: Int) - case downloadFailed(url: URL, error: Error) - case maxRetriesExceeded(url: URL, lastError: Error) - case writeFailed(error: Error) -} - -/// 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 -} - /// Generic package source information that applies to all installation methods. /// Takes all the necessary information from `RegistryItem`. struct PackageSource: Equatable, Codable { @@ -132,48 +94,3 @@ struct PackageSource: Equatable, Codable { case revision(String) } } - -/// Installation method enum with all supported types -enum InstallationMethod: Equatable { - /// For standard package manager installations - case standardPackage(source: PackageSource) - /// For packages that need to be built from source with custom build steps - case sourceBuild(source: PackageSource, command: String) - /// For direct binary downloads - case binaryDownload(source: PackageSource, url: URL) - /// For installations that aren't recognized - case unknown - - var packageName: String? { - switch self { - case .standardPackage(let source), - .sourceBuild(let source, _), - .binaryDownload(let source, _): - return source.pkgName - case .unknown: - return nil - } - } - - var version: String? { - switch self { - case .standardPackage(let source), - .sourceBuild(let source, _), - .binaryDownload(let source, _): - return source.version - case .unknown: - return nil - } - } - - var packageManagerType: PackageManagerType? { - switch self { - case .standardPackage(let source), - .sourceBuild(let source, _), - .binaryDownload(let source, _): - return source.type - case .unknown: - return nil - } - } -} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerType.swift b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift new file mode 100644 index 000000000..2a3982f12 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift @@ -0,0 +1,30 @@ +// +// 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/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift index 5e9721b52..f03ee6a2d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -10,7 +10,7 @@ import Foundation final class CargoPackageManager: PackageManagerProtocol { private let installationDirectory: URL - internal let shellClient: ShellClient + let shellClient: ShellClient init(installationDirectory: URL) { self.installationDirectory = installationDirectory diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index 889f6d19f..018569b75 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -10,7 +10,7 @@ import Foundation final class GithubPackageManager: PackageManagerProtocol { private let installationDirectory: URL - internal let shellClient: ShellClient + let shellClient: ShellClient init(installationDirectory: URL) { self.installationDirectory = installationDirectory diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift index eaf0cc2b3..e30e02c3d 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -9,7 +9,8 @@ import Foundation final class GolangPackageManager: PackageManagerProtocol { private let installationDirectory: URL - internal let shellClient: ShellClient + + let shellClient: ShellClient init(installationDirectory: URL) { self.installationDirectory = installationDirectory diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift index 3dd61a85c..e74470bb2 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -10,7 +10,7 @@ import Foundation final class NPMPackageManager: PackageManagerProtocol { private let installationDirectory: URL - internal let shellClient: ShellClient + let shellClient: ShellClient init(installationDirectory: URL) { self.installationDirectory = installationDirectory diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift index f63237997..4eb5ff262 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -10,7 +10,7 @@ import Foundation final class PipPackageManager: PackageManagerProtocol { private let installationDirectory: URL - internal let shellClient: ShellClient + let shellClient: ShellClient init(installationDirectory: URL) { self.installationDirectory = installationDirectory @@ -81,7 +81,7 @@ final class PipPackageManager: PackageManagerProtocol { } func isInstalled() async -> Bool { - let pipCommands = ["pip --version", "pip3 --version", "python -m pip --version"] + let pipCommands = ["pip3 --version", "python3 -m pip --version"] for command in pipCommands { do { let versionOutput = try await runCommand(command) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift index 76d811797..cd4515dd0 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -64,7 +64,7 @@ extension RegistryManager { } } - internal func handleUpdateError(_ error: Error) { + func handleUpdateError(_ error: Error) { if let regError = error as? RegistryManagerError { switch regError { case .invalidResponse(let statusCode): @@ -82,7 +82,7 @@ extension RegistryManager { } /// Attempts downloading from `url`, with error handling and a retry policy - internal static func download(from url: URL, attempt: Int = 1) async throws -> Data { + static func download(from url: URL, attempt: Int = 1) async throws -> Data { do { let (data, response) = try await URLSession.shared.data(from: url) @@ -108,7 +108,7 @@ extension RegistryManager { } /// Loads registry items from disk - internal func loadItemsFromDisk() -> [RegistryItem]? { + func loadItemsFromDisk() -> [RegistryItem]? { let registryPath = installPath.appending(path: "registry.json") let fileManager = FileManager.default diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index 0bce07684..bf10fe556 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -12,18 +12,18 @@ import ZIPFoundation final class RegistryManager { static let shared: RegistryManager = .init() - internal let installPath = Settings.shared.baseURL.appending(path: "Language Servers") + let installPath = Settings.shared.baseURL.appending(path: "Language Servers") /// The URL of where the registry.json file will be downloaded from - internal let registryURL = URL( + let registryURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" )! /// The URL of where the checksums.txt file will be downloaded from - internal let checksumURL = URL( + let checksumURL = URL( string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" )! - /// Rreference to cached registry data. Will be removed from memory after a certain amount of time. + /// 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? @@ -186,7 +186,7 @@ final class RegistryManager { } /// Create the appropriate package manager for the given installation method - internal func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { + func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { switch method.packageManagerType { case .npm: return NPMPackageManager(installationDirectory: installPath) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift new file mode 100644 index 000000000..ac0da00b3 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift @@ -0,0 +1,15 @@ +// +// 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/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index b7d8a2087..d14d1a70a 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -7,11 +7,11 @@ import os.log import JSONRPC +import SwiftUI import Foundation import LanguageClient import LanguageServerProtocol import CodeEditLanguages -import SwiftUICore /// `LSPService` is a service class responsible for managing the lifecycle and event handling /// of Language Server Protocol (LSP) clients within the CodeEdit application. It handles the initialization, diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index d4b6b67aa..d7786ffc7 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -85,13 +85,13 @@ struct SettingsView: View { icon: .system("externaldrive.fill") ) ), -// .init( -// SettingsPage( -// .languageServers, -// baseColor: Color(hex: "#6A69DC"), // Purple -// icon: .system("cube.box.fill") -// ) -// ), + .init( + SettingsPage( + .languageServers, + baseColor: Color(hex: "#6A69DC"), // Purple + icon: .system("cube.box.fill") + ) + ), .init( SettingsPage( .developer, From f358ab92b649c326a310ab950b838a59a449ec7b Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 19 May 2025 01:29:55 -0700 Subject: [PATCH 37/38] Refactors --- .../RegistryManager+HandleRegistryFile.swift | 12 ++++---- .../LSP/Registry/RegistryManager.swift | 28 +++++++------------ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift index cd4515dd0..9a3b5621d 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -59,7 +59,7 @@ extension RegistryManager { NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) } catch { - print("Error details: \(error)") + logger.error("Error updating: \(error)") handleUpdateError(RegistryManagerError.writeFailed(error: error)) } } @@ -68,16 +68,16 @@ extension RegistryManager { if let regError = error as? RegistryManagerError { switch regError { case .invalidResponse(let statusCode): - print("Invalid response received: \(statusCode)") + logger.error("Invalid response received: \(statusCode)") case let .downloadFailed(url, error): - print("Download failed for \(url.absoluteString): \(error.localizedDescription)") + logger.error("Download failed for \(url.absoluteString): \(error.localizedDescription)") case let .maxRetriesExceeded(url, error): - print("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") + logger.error("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") case let .writeFailed(error): - print("Failed to write files to disk: \(error.localizedDescription)") + logger.error("Failed to write files to disk: \(error.localizedDescription)") } } else { - print("Unexpected registry error: \(error.localizedDescription)") + logger.error("Unexpected registry error: \(error.localizedDescription)") } } diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift index bf10fe556..a2d0945c5 100644 --- a/CodeEdit/Features/LSP/Registry/RegistryManager.swift +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -5,6 +5,7 @@ // Created by Abe Malla on 1/29/25. // +import OSLog import Foundation import ZIPFoundation @@ -12,6 +13,8 @@ import ZIPFoundation final class RegistryManager { static let shared: RegistryManager = .init() + 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 @@ -143,24 +146,13 @@ final class RegistryManager { fail failed: Bool ) { if failed { - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": id, - "action": "update", - "title": "Could not install \(activityName)", - "isLoading": false - ] - ) - NotificationCenter.default.post( - name: .taskNotification, - object: nil, - userInfo: [ - "id": id, - "action": "deleteWithDelay", - "delay": 5.0, - ] + NotificationManager.shared.post( + iconSymbol: "xmark.circle", + iconColor: .clear, + title: "Could not install \(activityName)", + description: "There was a problem during installation.", + actionButtonTitle: "Done", + action: {}, ) } else { NotificationCenter.default.post( From daae20551128790d451da6b11f168236ab5c148e Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 19 May 2025 02:27:50 -0700 Subject: [PATCH 38/38] Refactors --- .../GithubPackageManager.swift | 39 ++++++++++++++----- .../PackageSourceParser.swift | 2 +- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift index 018569b75..c91a8d1a7 100644 --- a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -30,18 +30,17 @@ final class GithubPackageManager: PackageManagerProtocol { } 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) - 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 } @@ -64,11 +63,29 @@ final class GithubPackageManager: PackageManagerProtocol { } private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { - _ = try await URLSession.shared.data(from: url) + 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, @@ -84,7 +101,11 @@ final class GithubPackageManager: PackageManagerProtocol { private func installFromSource(_ source: PackageSource, _ command: String) async throws { let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) do { - _ = try await executeInDirectory(in: installPath.path, ["git", "clone", source.repositoryUrl!]) + 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 { diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift index 803d061fa..666f05a73 100644 --- a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -40,7 +40,7 @@ enum PackageSourceParser { false } - let source = PackageSource( + var source = PackageSource( sourceId: sourceId, type: isSourceBuild ? .sourceBuild : .github, pkgName: packageName,