diff --git a/.github/scripts/test_app.sh b/.github/scripts/test_app.sh index b4ed47056d..cd5354c4a1 100755 --- a/.github/scripts/test_app.sh +++ b/.github/scripts/test_app.sh @@ -14,8 +14,13 @@ echo "SwiftLint Version: $(swiftlint --version)" export LC_CTYPE=en_US.UTF-8 +# xcbeautify flags: +# - renderer: render to gh actions +# - q: quiet output +# - is-ci: include test results in output + set -o pipefail && arch -"${ARCH}" xcodebuild \ -scheme CodeEdit \ -destination "platform=OS X,arch=${ARCH}" \ -skipPackagePluginValidation \ - clean test | xcpretty + clean test | xcbeautify --renderer github-actions -q --is-ci diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 8d765b02fd..233f378ef3 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 */; }; @@ -21,15 +22,17 @@ 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; }; 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; }; 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; }; - 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */; }; + 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */; }; 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6C66C31229D05CDC00DE9ED2 /* GRDB */; }; 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F329CD142C00235D17 /* CollectionConcurrencyKit */; }; 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; }; 6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; }; + 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; }; 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; }; 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; }; 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; }; + 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */; }; 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CAAF69229BCC71C00A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CAAF69429BCD78600A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -38,6 +41,8 @@ 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB94D022CA1205100E8651C /* AsyncAlgorithms */; }; 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; + 6CCF6DD32E26D48F00B94F75 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */; }; + 6CCF73D02E26DE3200B94F75 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */; }; 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; B6FF04782B6C08AC002C2C78 /* DefaultThemes in Resources */ = {isa = PBXBuildFile; fileRef = B6FF04772B6C08AC002C2C78 /* DefaultThemes */; }; @@ -165,18 +170,22 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, + 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */, + 6CCF73D02E26DE3200B94F75 /* SwiftTerm in Frameworks */, + 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */, 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, + 6CCF6DD32E26D48F00B94F75 /* SwiftTerm in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, 5E4485612DF600D9008BBE69 /* AboutWindow in Frameworks */, - 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */, 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, @@ -188,6 +197,7 @@ 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, + 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,13 +326,17 @@ 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */, 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, - 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, + 30818CB42D4E563900967860 /* ZIPFoundation */, 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, + 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, + 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */, + 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -423,11 +437,12 @@ 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, - 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1656,7 +1671,7 @@ repositoryURL = "https://github.com/ChimeHQ/LanguageClient"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.8.0; + minimumVersion = 0.8.2; }; }; 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { @@ -1667,6 +1682,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"; @@ -1683,6 +1706,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"; @@ -1731,14 +1762,6 @@ minimumVersion = 1.0.0; }; }; - 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; - requirement = { - kind = revision; - revision = 384776a4e24d08833ac7c6b8c6f6c7490323c845; - }; - }; 6C66C31129D05CC800DE9ED2 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/groue/GRDB.swift.git"; @@ -1755,6 +1778,14 @@ minimumVersion = 0.2.0; }; }; + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = exactVersion; + version = 0.15.0; + }; + }; 6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditKit"; @@ -1771,6 +1802,14 @@ minimumVersion = 1.2.0; }; }; + 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; + }; + }; 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; @@ -1779,12 +1818,12 @@ version = 1.0.1; }; }; - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + repositoryURL = "https://github.com/thecoolwinter/SwiftTerm"; requirement = { - kind = exactVersion; - version = 0.14.1; + branch = codeedit; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -1795,6 +1834,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" */; @@ -1839,10 +1883,9 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; - 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = { + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; - package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; - productName = SwiftTerm; + productName = CodeEditSourceEditor; }; 6C66C31229D05CDC00DE9ED2 /* GRDB */ = { isa = XCSwiftPackageProductDependency; @@ -1862,6 +1905,11 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; 6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = { isa = XCSwiftPackageProductDependency; package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */; @@ -1882,6 +1930,11 @@ package = 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; + 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; 6CB4463F2B6DFF3A00539ED0 /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; @@ -1899,6 +1952,15 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftTerm; + }; + 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb15291f44..32dd4e6728 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "caf7678c3c52812febb80907a6a451d5e91a20058bbe45d250d7234c51299e91", + "originHash" : "01191ca9685501db65981a6fd21ab2d11c32196633d4cb776b5bb25908ed212f", "pins" : [ { "identity" : "aboutwindow", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", "state" : { - "revision" : "afc57523b05c209496a221655c2171c0624b51d3", - "version" : "0.14.1" + "revision" : "c0d2c90aecca04ce2be2ff29c39a3add5951b539", + "version" : "0.15.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "d65c2a4b23a52f69d0b3a113124d7434c7af07fa", - "version" : "0.11.6" + "revision" : "d7ac3f11f22ec2e820187acce8f3a3fb7aa8ddec", + "version" : "0.12.1" } }, { @@ -91,15 +91,6 @@ "version" : "2.1.0" } }, - { - "identity" : "globpattern", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/GlobPattern", - "state" : { - "revision" : "4ebb9e89e07cc475efa74f87dc6d21f4a9e060f8", - "version" : "0.1.1" - } - }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -123,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageClient", "state" : { - "revision" : "f8fdeaed850fbc3e542cd038e952758887f6be5d", - "version" : "0.8.0" + "revision" : "4f28cc3cad7512470275f65ca2048359553a86f5", + "version" : "0.8.2" } }, { @@ -132,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", "state" : { - "revision" : "ac76fccf0e981c8e30c5ee4de1b15adc1decd697", - "version" : "0.13.2" + "revision" : "f7879c782c0845af9c576de7b8baedd946237286", + "version" : "0.14.0" } }, { @@ -168,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { - "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", - "version" : "1.8.1" + "revision" : "f1d74e1642956f0300756ad8d1d64e9034857bc3", + "version" : "2.0.0" } }, { @@ -208,6 +199,15 @@ "version" : "1.1.3" } }, + { + "identity" : "swift-glob", + "kind" : "remoteSourceControl", + "location" : "https://github.com/davbeck/swift-glob", + "state" : { + "revision" : "07ba6f47d903a0b1b59f12ca70d6de9949b975d6", + "version" : "0.2.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -238,9 +238,10 @@ { "identity" : "swiftterm", "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm", + "location" : "https://github.com/thecoolwinter/SwiftTerm", "state" : { - "revision" : "384776a4e24d08833ac7c6b8c6f6c7490323c845" + "branch" : "codeedit", + "revision" : "2f36f54742d3882e69ff009d084e8675b80934bd" } }, { @@ -248,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", "state" : { - "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", - "version" : "0.9.0" + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" } }, { @@ -275,8 +276,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", - "version" : "0.9.0" + "revision" : "91df6fc9bd817f9712331a4a3e826f7bdc823e1d", + "version" : "0.9.1" } }, { @@ -284,8 +285,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tree-sitter/tree-sitter", "state" : { - "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", - "version" : "0.23.2" + "revision" : "bf655c0beaf4943573543fa77c58e8006ff34971", + "version" : "0.25.6" } }, { @@ -296,6 +297,15 @@ "revision" : "5168cf1ce9579b35ad00706fafef441418d8011f", "version" : "1.0.0" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } } ], "version" : 3 diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index 7945d1e715..5581ef7fc2 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -10,6 +10,7 @@ import CodeEditSymbols import CodeEditSourceEditor import OSLog +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "AppDelegate") private let updater = SoftwareUpdater() @@ -121,6 +122,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } + // MARK: - Should Terminate + /// Defers the application terminate message until we've finished cleanup. /// /// All paths _must_ call `NSApplication.shared.reply(toApplicationShouldTerminate: true)` as soon as possible. @@ -255,20 +258,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { /// Terminates running language servers. Used during app termination to ensure resources are freed. private func terminateLanguageServers() { - Task { - await lspService.stopAllServers() - await MainActor.run { - NSApplication.shared.reply(toApplicationShouldTerminate: true) + Task { @MainActor in + let task = TaskNotificationModel( + id: "appdelegate.terminate_language_servers", + title: "Stopping Language Servers", + message: "Stopping running language server processes...", + isLoading: true + ) + + if !lspService.languageClients.isEmpty { + TaskNotificationHandler.postTask(action: .create, model: task) } + + try await withTimeout( + duration: .seconds(5.0), + onTimeout: { + // Stop-gap measure to ensure we don't hang on CMD-Q + await self.lspService.killAllServers() + }, + operation: { + await self.lspService.stopAllServers() + } + ) + + TaskNotificationHandler.postTask(action: .delete, model: task) + NSApplication.shared.reply(toApplicationShouldTerminate: true) } } /// Terminates all running tasks. Used during app termination to ensure resources are freed. private func terminateTasks() { - let documents = CodeEditDocumentController.shared.documents.compactMap({ $0 as? WorkspaceDocument }) - documents.forEach { workspace in - workspace.taskManager?.stopAllTasks() + let task = TaskNotificationModel( + id: "appdelegate.terminate_tasks", + title: "Terminating Tasks", + message: "Interrupting all running tasks before quitting...", + isLoading: true + ) + + let taskManagers = CodeEditDocumentController.shared.documents + .compactMap({ $0 as? WorkspaceDocument }) + .compactMap({ $0.taskManager }) + + if taskManagers.reduce(0, { $0 + $1.activeTasks.count }) > 0 { + TaskNotificationHandler.postTask(action: .create, model: task) } + + taskManagers.forEach { manager in + manager.stopAllTasks() + } + + TaskNotificationHandler.postTask(action: .delete, model: task) } } diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift index ae0fdc645a..77ecc77fa8 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift @@ -25,28 +25,35 @@ import Combine /// Remember to manage your task notifications appropriately. You should either delete task /// notifications manually or schedule their deletion in advance using the `deleteWithDelay` method. /// +/// Some tasks should be restricted to a specific workspace. To do this, specify the `workspace` attribute in the +/// notification's `userInfo` dictionary as a `URL`, or use the `toWorkspace` parameter on +/// ``TaskNotificationHandler/postTask(toWorkspace:action:model:)``. +/// /// ## Available Methods /// - `create`: /// Creates a new Task Notification. /// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). /// - `createWithPriority`: /// Creates a new Task Notification and inserts it at the start of the array. /// This ensures it appears in the activity viewer even if there are other task notifications before it. /// **Note:** This should only be used for important notifications! /// Required fields: `id` (String), `action` (String), `title` (String). -/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool), `workspace` (URL). /// - `update`: /// Updates an existing task notification. It's important to pass the same `id` to update the correct task. /// Required fields: `id` (String), `action` (String). -/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool). +/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool), +/// `workspace` (URL). /// - `delete`: /// Deletes an existing task notification. /// Required fields: `id` (String), `action` (String). +/// Optional field: `workspace` (URL). /// - `deleteWithDelay`: /// Deletes an existing task notification after a certain `TimeInterval`. /// Required fields: `id` (String), `action` (String), `delay` (Double). -/// **Important:** When specifying the delay, ensure it's a double. +/// Optional field: `workspace` (URL). +/// **Important:** When specifying the delay, ensure it's a double. /// For example, '2' would be invalid because it would count as an integer, use '2.0' instead. /// /// ## Example Usage: @@ -101,13 +108,46 @@ import Combine /// } /// ``` /// +/// You can also use the static helper method instead of creating dictionaries manually: +/// ```swift +/// TaskNotificationHandler.postTask(action: .create, model: .init(id: "task_id", "title": "New Task")) +/// ``` +/// /// - Important: Please refer to ``CodeEdit/TaskNotificationModel`` and ensure you pass the correct values. final class TaskNotificationHandler: ObservableObject { @Published private(set) var notifications: [TaskNotificationModel] = [] + var workspaceURL: URL? var cancellables: Set = [] + enum Action: String { + case create + case createWithPriority + case update + case delete + case deleteWithDelay + } + + /// Post a new task. + /// - Parameters: + /// - toWorkspace: The workspace to restrict the task to. Defaults to `nil`, which is received by all workspaces. + /// - action: The action being taken on the task. + /// - model: The task contents. + static func postTask(toWorkspace: URL? = nil, action: Action, model: TaskNotificationModel) { + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: [ + "id": model.id, + "title": model.title, + "message": model.message as Any, + "percentage": model.percentage as Any, + "isLoading": model.isLoading, + "action": action.rawValue, + "workspace": toWorkspace as Any + ]) + } + /// Initialises a new `TaskNotificationHandler` and starts observing for task notifications. - init() { + init(workspaceURL: URL? = nil) { + self.workspaceURL = workspaceURL + NotificationCenter.default .publisher(for: .taskNotification) .receive(on: DispatchQueue.main) @@ -127,21 +167,25 @@ final class TaskNotificationHandler: ObservableObject { private func handleNotification(_ notification: Notification) { guard let userInfo = notification.userInfo, let taskID = userInfo["id"] as? String, - let action = userInfo["action"] as? String else { return } + let actionRaw = userInfo["action"] as? String, + let action = Action(rawValue: actionRaw) else { return } + + // If a workspace is specified and doesn't match, don't do anything with this task. + if let workspaceURL = userInfo["workspace"] as? URL, workspaceURL != self.workspaceURL { + return + } switch action { - case "create", "createWithPriority": + case .create, .createWithPriority: createTask(task: userInfo) - case "update": + case .update: updateTask(task: userInfo) - case "delete": + case .delete: deleteTask(taskID: taskID) - case "deleteWithDelay": + case .deleteWithDelay: if let delay = userInfo["delay"] as? Double { deleteTaskAfterDelay(taskID: taskID, delay: delay) } - default: - break } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index b544b6367d..ce3a4d7c94 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -266,6 +266,14 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor return true } + /// Loads the ``fileDocument`` property with a new ``CodeFileDocument`` and registers it with the shared + /// ``CodeEditDocumentController``. + func loadCodeFile() throws { + let codeFile = try CodeFileDocument(contentsOf: resolvedURL, ofType: contentType?.identifier ?? "") + CodeEditDocumentController.shared.addDocument(codeFile) + self.fileDocument = codeFile + } + // MARK: Statics /// The default `FileManager` instance static let fileManager = FileManager.default diff --git a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift b/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift index c2cb419659..4909417ee3 100644 --- a/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift +++ b/CodeEdit/Features/CodeEditUI/Views/KeyValueTable.swift @@ -13,7 +13,7 @@ struct KeyValueItem: Identifiable, Equatable { let value: String } -private struct NewListTableItemView: View { +private struct NewListTableItemView: View { @Environment(\.dismiss) var dismiss @@ -24,17 +24,21 @@ private struct NewListTableItemView: View { let valueColumnName: String let newItemInstruction: String let validKeys: [String] - let headerView: AnyView? + let headerView: HeaderView? var completion: (String, String) -> Void init( + key: String? = nil, + value: String? = nil, _ keyColumnName: String, _ valueColumnName: String, _ newItemInstruction: String, validKeys: [String], - headerView: AnyView? = nil, + headerView: HeaderView? = nil, completion: @escaping (String, String) -> Void ) { + self.key = key ?? "" + self.value = value ?? "" self.keyColumnName = keyColumnName self.valueColumnName = valueColumnName self.newItemInstruction = newItemInstruction @@ -62,7 +66,11 @@ private struct NewListTableItemView: View { TextField(valueColumnName, text: $value) .textFieldStyle(.plain) } header: { - headerView + if HeaderView.self == EmptyView.self { + Text(newItemInstruction) + } else { + headerView + } } } .formStyle(.grouped) @@ -94,17 +102,18 @@ private struct NewListTableItemView: View { } } -struct KeyValueTable: View { +struct KeyValueTable: View { @Binding var items: [String: String] let validKeys: [String] let keyColumnName: String let valueColumnName: String let newItemInstruction: String - let header: () -> Header + let newItemHeader: () -> Header + let actionBarTrailing: () -> ActionBarView - @State private var showingModal = false - @State private var selection: UUID? + @State private var editingItem: KeyValueItem? + @State private var selection: Set = [] @State private var tableItems: [KeyValueItem] = [] init( @@ -113,14 +122,16 @@ struct KeyValueTable: View { keyColumnName: String, valueColumnName: String, newItemInstruction: String, - @ViewBuilder header: @escaping () -> Header = { EmptyView() } + @ViewBuilder newItemHeader: @escaping () -> Header = { EmptyView() }, + @ViewBuilder actionBarTrailing: @escaping () -> ActionBarView = { EmptyView() } ) { self._items = items self.validKeys = validKeys self.keyColumnName = keyColumnName self.valueColumnName = valueColumnName self.newItemInstruction = newItemInstruction - self.header = header + self.newItemHeader = newItemHeader + self.actionBarTrailing = actionBarTrailing } var body: some View { @@ -132,11 +143,24 @@ struct KeyValueTable: View { Text(item.value) } } - .frame(height: 200) + .contextMenu( + forSelectionType: UUID.self, + menu: { selectedItems in + Button("Edit") { + editItem(id: selectedItems.first) + } + Button("Remove") { + removeItem(selectedItems) + } + }, + primaryAction: { selectedItems in + editItem(id: selectedItems.first) + } + ) .actionBar { HStack(spacing: 2) { Button { - showingModal = true + editingItem = KeyValueItem(key: "", value: "") } label: { Image(systemName: "plus") } @@ -149,38 +173,64 @@ struct KeyValueTable: View { } label: { Image(systemName: "minus") } - .disabled(selection == nil) - .opacity(selection == nil ? 0.5 : 1) + .disabled(selection.isEmpty) + .opacity(selection.isEmpty ? 0.5 : 1) + + Spacer() + + actionBarTrailing() } - Spacer() } - .sheet(isPresented: $showingModal) { + .sheet(item: $editingItem) { item in NewListTableItemView( + key: item.key, + value: item.value, keyColumnName, valueColumnName, newItemInstruction, validKeys: validKeys, - headerView: AnyView(header()) + headerView: newItemHeader() ) { key, value in items[key] = value - updateTableItems() - showingModal = false + editingItem = nil } } .cornerRadius(6) - .onAppear(perform: updateTableItems) + .onAppear { + updateTableItems(items) + if let first = tableItems.first?.id { + selection = [first] + } + selection = [] + } + .onChange(of: items) { newValue in + updateTableItems(newValue) + } } - private func updateTableItems() { - tableItems = items.map { KeyValueItem(key: $0.key, value: $0.value) } + private func updateTableItems(_ newValue: [String: String]) { + tableItems = items + .sorted { $0.key < $1.key } + .map { KeyValueItem(key: $0.key, value: $0.value) } } private func removeItem() { - guard let selectedId = selection else { return } - if let selectedItem = tableItems.first(where: { $0.id == selectedId }) { - items.removeValue(forKey: selectedItem.key) - updateTableItems() + removeItem(selection) + self.selection.removeAll() + } + + private func removeItem(_ selection: Set) { + for selectedId in selection { + if let selectedItem = tableItems.first(where: { $0.id == selectedId }) { + items.removeValue(forKey: selectedItem.key) + } + } + } + + private func editItem(id: UUID?) { + guard let id, let item = tableItems.first(where: { $0.id == id }) else { + return } - selection = nil + editingItem = item } } diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 61b32428fa..bab03a52f8 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -14,6 +14,7 @@ import CodeEditTextView import CodeEditLanguages import Combine import OSLog +import TextStory enum CodeFileError: Error { case failedToDecode @@ -161,15 +162,39 @@ final class CodeFileDocument: NSDocument, ObservableObject { convertedString: &nsString, usedLossyConversion: nil ) - if let validEncoding = FileEncoding(rawEncoding), let nsString { - self.sourceEncoding = validEncoding - self.content = NSTextStorage(string: nsString as String) - } else { + guard let validEncoding = FileEncoding(rawEncoding), let nsString else { Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)") + return + } + self.sourceEncoding = validEncoding + if let content { + registerContentChangeUndo(fileURL: fileURL, nsString: nsString, content: content) + content.mutableString.setString(nsString as String) + } else { + self.content = NSTextStorage(string: nsString as String) } NotificationCenter.default.post(name: Self.didOpenNotification, object: self) } + /// If this file is already open and being tracked by an undo manager, we register an undo mutation + /// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit + /// while the file was displayed in CodeEdit. + /// + /// - Note: This is inefficient memory-wise. We could do a diff of the file and only register the + /// mutations that would recreate the diff. However, that would instead be CPU intensive. + /// Tradeoffs. + private func registerContentChangeUndo(fileURL: URL?, nsString: NSString, content: NSTextStorage) { + guard let fileURL else { return } + // If there's an undo manager, register a mutation replacing the entire contents. + let mutation = TextMutation( + string: nsString as String, + range: NSRange(location: 0, length: content.length), + limit: content.length + ) + let undoManager = self.findWorkspace()?.undoRegistration.managerIfExists(forFile: fileURL) + undoManager?.registerMutation(mutation) + } + // MARK: - Autosave /// Triggered when change occurred @@ -217,6 +242,43 @@ final class CodeFileDocument: NSDocument, ObservableObject { } } + // MARK: - External Changes + + /// Handle the notification that the represented file item changed. + /// + /// We check if a file has been modified and can be read again to display to the user. + /// To determine if a file has changed, we check the modification date. If it's different from the stored one, + /// we continue. + /// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the + /// file. + override func presentedItemDidChange() { + if fileModificationDate != getModificationDate() { + guard isDocumentEdited else { + fileModificationDate = getModificationDate() + if let fileURL, let fileType { + // This blocks the presented item thread intentionally. If we don't wait, we'll receive more updates + // that the file has changed and we'll end up dispatching multiple reads. + // The presented item thread expects this operation to by synchronous anyways. + DispatchQueue.main.asyncAndWait { + try? self.read(from: fileURL, ofType: fileType) + } + } + return + } + } + + super.presentedItemDidChange() + } + + /// Helper to find the last modified date of the represented file item. + /// + /// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the + /// alternative stores the date that existed when we last read the file. + private func getModificationDate() -> Date? { + guard let path = fileURL?.absolutePath else { return nil } + return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date + } + // MARK: - Close override func close() { diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift index 7551c0bedc..a15ac9311e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift @@ -76,6 +76,7 @@ final class CodeEditSplitViewController: NSSplitViewController { .environmentObject(statusBarViewModel) .environmentObject(utilityAreaModel) .environmentObject(taskManager) + .environmentObject(workspace.undoRegistration) } } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift index e797f9b5bd..ea9feebb2e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift @@ -82,7 +82,7 @@ extension CodeEditWindowController { isCollapsed: { self.workspace?.utilityAreaModel?.isCollapsed ?? true }, getPrevCollapsed: { self.prevUtilityAreaCollapsed }, setPrevCollapsed: { self.prevUtilityAreaCollapsed = $0 }, - toggle: { CommandManager.shared.executeCommand("open.drawer") } + toggle: { self.workspace?.utilityAreaModel?.togglePanel(animation: false) } ), PanelDescriptor( isCollapsed: { self.toolbarCollapsed }, diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index 34543e8be1..be082d6fee 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -143,6 +143,21 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs } } + /// Opens the search navigator and focuses the search field + @IBAction func openSearchNavigator(_ sender: Any? = nil) { + if navigatorCollapsed { + toggleFirstPanel() + } + + if let navigatorViewModel = navigatorSidebarViewModel, + let searchTab = navigatorViewModel.tabItems.first(where: { $0 == .search }) { + DispatchQueue.main.async { + self.workspace?.searchState?.shouldFocusSearchField = true + navigatorViewModel.setNavigatorTab(tab: searchTab) + } + } + } + @IBAction func openQuickly(_ sender: Any?) { if let workspace, let state = workspace.openQuicklyViewModel { if let quickOpenPanel { diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift index 261a4d224e..2ee8305a5a 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift @@ -29,11 +29,14 @@ extension WorkspaceDocument { @Published var searchResultsCount: Int = 0 /// Stores the user's input, shown when no files are found, and persists across navigation items. @Published var searchQuery: String = "" + @Published var replaceText: String = "" @Published var indexStatus: IndexStatus = .none @Published var findNavigatorStatus: FindNavigatorStatus = .none + @Published var shouldFocusSearchField: Bool = false + unowned var workspace: WorkspaceDocument var tempSearchResults = [SearchResultModel]() var caseSensitive: Bool = false diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index a47fdbba83..9d20cb57d2 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -45,6 +45,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() + @Published var notificationPanel = NotificationPanelViewModel() private var cancellables = Set() @@ -161,7 +163,9 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { workspaceURL: url ) } + self.taskNotificationHandler.workspaceURL = url + workspaceFileManager?.addObserver(undoRegistration) editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) } diff --git a/CodeEdit/Features/Editor/Models/Editor+History.swift b/CodeEdit/Features/Editor/Models/Editor/Editor+History.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/Editor+History.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor+History.swift diff --git a/CodeEdit/Features/Editor/Models/Editor+TabSwitch.swift b/CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/Editor+TabSwitch.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor/Editor.swift similarity index 89% rename from CodeEdit/Features/Editor/Models/Editor.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor.swift index d6fcfa767d..0ec2765154 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor/Editor.swift @@ -55,23 +55,31 @@ final class Editor: ObservableObject, Identifiable { var id = UUID() weak var parent: SplitViewData? + weak var workspace: WorkspaceDocument? init() { self.tabs = [] self.temporaryTab = nil self.parent = nil + self.workspace = nil } init( files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, - parent: SplitViewData? = nil + parent: SplitViewData? = nil, + workspace: WorkspaceDocument? = nil ) { - self.tabs = [] self.parent = parent - files.forEach { openTab(file: $0) } - self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(file: files.first!)) + self.workspace = workspace + // If we open the files without a valid workspace, we risk creating a file we lose track of but stays in memory + if workspace != nil { + files.forEach { openTab(file: $0) } + } else { + self.tabs = OrderedSet(files.map { EditorInstance(workspace: workspace, file: $0) }) + } + self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!)) self.temporaryTab = temporaryTab } @@ -79,10 +87,12 @@ final class Editor: ObservableObject, Identifiable { files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, - parent: SplitViewData? = nil + parent: SplitViewData? = nil, + workspace: WorkspaceDocument? = nil ) { self.tabs = [] self.parent = parent + self.workspace = workspace files.forEach { openTab(file: $0.file) } self.selectedTab = selectedTab ?? tabs.first self.temporaryTab = temporaryTab @@ -135,7 +145,7 @@ final class Editor: ObservableObject, Identifiable { clearFuture() } if file != selectedTab?.file { - addToHistory(EditorInstance(file: file)) + addToHistory(EditorInstance(workspace: workspace, file: file)) } removeTab(file) if let selectedTab { @@ -165,7 +175,7 @@ final class Editor: ObservableObject, Identifiable { /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let item = EditorInstance(file: file) + let item = EditorInstance(workspace: workspace, file: file) // Item is already opened in a tab. guard !tabs.contains(item) || !asTemporary else { selectedTab = item @@ -207,7 +217,7 @@ final class Editor: ObservableObject, Identifiable { /// - index: Index where the tab needs to be added. If nil, it is added to the back. /// - fromHistory: Indicates whether the tab has been opened from going back in history. func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { - let item = Tab(file: file) + let item = Tab(workspace: workspace, file: file) if let index { tabs.insert(item, at: index) } else { @@ -231,18 +241,12 @@ final class Editor: ObservableObject, Identifiable { } private func openFile(item: Tab) throws { - guard item.file.fileDocument == nil else { + // If this isn't attached to a workspace, loading a new NSDocument will cause a loose document we can't close + guard item.file.fileDocument == nil && workspace != nil else { return } - let contentType = item.file.resolvedURL.contentType - let codeFile = try CodeFileDocument( - for: item.file.url, - withContentsOf: item.file.resolvedURL, - ofType: contentType?.identifier ?? "" - ) - item.file.fileDocument = codeFile - CodeEditDocumentController.shared.addDocument(codeFile) + try item.file.loadCodeFile() } /// Check if tab can be closed diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index 7ec13c53bd..fd11333bb7 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -13,33 +13,105 @@ import CodeEditSourceEditor /// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish /// the user's current location in a file. -class EditorInstance: Hashable { - // Public - +class EditorInstance: ObservableObject, Hashable { /// The file presented in this editor instance. let file: CEWorkspaceFile /// A publisher for the user's current location in a file. - var cursorPositions: AnyPublisher<[CursorPosition], Never> { - cursorSubject.eraseToAnyPublisher() - } + @Published var cursorPositions: [CursorPosition] + @Published var scrollPosition: CGPoint? - // Public TextViewCoordinator APIs + @Published var findText: String? + var findTextSubject: PassthroughSubject - var rangeTranslator: RangeTranslator? + @Published var replaceText: String? + var replaceTextSubject: PassthroughSubject - // Internal Combine subjects + var rangeTranslator: RangeTranslator = RangeTranslator() - private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([]) + private var cancellables: Set = [] - // MARK: - Init, Hashable, Equatable + // MARK: - Init - init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) { + init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { self.file = file - self.cursorSubject.send(cursorPositions) - self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) + let url = file.url + let editorState = EditorStateRestoration.shared?.restorationState(for: url) + + findText = workspace?.searchState?.searchQuery + findTextSubject = PassthroughSubject() + replaceText = workspace?.searchState?.replaceText + replaceTextSubject = PassthroughSubject() + + self.cursorPositions = ( + cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)] + ) + self.scrollPosition = editorState?.scrollPosition + + // Setup listeners + + Publishers.CombineLatest( + $cursorPositions.removeDuplicates(), + $scrollPosition + .debounce(for: .seconds(0.1), scheduler: RunLoop.main) // This can trigger *very* often + .removeDuplicates() + ) + .sink { (cursorPositions, scrollPosition) in + EditorStateRestoration.shared?.updateRestorationState( + for: url, + data: .init(cursorPositions: cursorPositions, scrollPosition: scrollPosition ?? .zero) + ) + } + .store(in: &cancellables) + + listenToFindText(workspace: workspace) + listenToReplaceText(workspace: workspace) + } + + // MARK: - Find/Replace Listeners + + func listenToFindText(workspace: WorkspaceDocument?) { + workspace?.searchState?.$searchQuery + .receive(on: RunLoop.main) + .sink { [weak self] newQuery in + if self?.findText != newQuery { + self?.findText = newQuery + } + } + .store(in: &cancellables) + findTextSubject + .receive(on: RunLoop.main) + .sink { [weak workspace, weak self] newFindText in + if let newFindText, workspace?.searchState?.searchQuery != newFindText { + workspace?.searchState?.searchQuery = newFindText + } + self?.findText = workspace?.searchState?.searchQuery + } + .store(in: &cancellables) + } + + func listenToReplaceText(workspace: WorkspaceDocument?) { + workspace?.searchState?.$replaceText + .receive(on: RunLoop.main) + .sink { [weak self] newText in + if self?.replaceText != newText { + self?.replaceText = newText + } + } + .store(in: &cancellables) + replaceTextSubject + .receive(on: RunLoop.main) + .sink { [weak workspace, weak self] newReplaceText in + if let newReplaceText, workspace?.searchState?.replaceText != newReplaceText { + workspace?.searchState?.replaceText = newReplaceText + } + self?.replaceText = workspace?.searchState?.replaceText + } + .store(in: &cancellables) } + // MARK: - Hashable, Equatable + func hash(into hasher: inout Hasher) { hasher.combine(file) } @@ -53,19 +125,17 @@ class EditorInstance: Hashable { /// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range. class RangeTranslator: TextViewCoordinator { private weak var textViewController: TextViewController? - private var cursorSubject: CurrentValueSubject<[CursorPosition], Never> - - init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) { - self.cursorSubject = cursorSubject - } - func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { - self.cursorSubject.send(controller.cursorPositions) - } + init() { } func prepareCoordinator(controller: TextViewController) { self.textViewController = controller - self.cursorSubject.send(controller.cursorPositions) + } + + func controllerDidAppear(controller: TextViewController) { + if controller.isEditable && controller.isSelectable { + controller.view.window?.makeFirstResponder(controller.textView) + } } func destroy() { diff --git a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift similarity index 82% rename from CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift rename to CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift index 48a8fe1334..b8692f974c 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift @@ -14,8 +14,7 @@ extension EditorManager { /// - Parameter workspace: The workspace to retrieve state from. func restoreFromState(_ workspace: WorkspaceDocument) { do { - guard let fileManager = workspace.workspaceFileManager, - let data = workspace.getFromWorkspaceState(.openTabs) as? Data else { + guard let data = workspace.getFromWorkspaceState(.openTabs) as? Data else { return } @@ -35,7 +34,7 @@ extension EditorManager { return } - fixRestoredEditorLayout(state.groups, fileManager: fileManager) + try fixRestoredEditorLayout(state.groups, workspace: workspace) self.editorLayout = state.groups self.activeEditor = activeEditor @@ -54,29 +53,29 @@ extension EditorManager { /// - Parameters: /// - group: The tab group to fix. /// - fileManager: The file manager to use to map files. - private func fixRestoredEditorLayout(_ group: EditorLayout, fileManager: CEWorkspaceFileManager) { + private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) throws { switch group { case let .one(data): - fixEditor(data, fileManager: fileManager) + try fixEditor(data, workspace: workspace) case let .vertical(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout(group, workspace: workspace) } case let .horizontal(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout(group, workspace: workspace) } } } - private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> Editor? { + private func findEditorLayout(group: EditorLayout, searchFor id: UUID) throws -> Editor? { switch group { case let .one(data): return data.id == id ? data : nil case let .vertical(splitData): - return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first case let .horizontal(splitData): - return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first } } @@ -88,14 +87,25 @@ extension EditorManager { /// - Parameters: /// - data: The tab group to fix. /// - fileManager: The file manager to use to map files.a - private func fixEditor(_ editor: Editor, fileManager: CEWorkspaceFileManager) { + private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) throws { + guard let fileManager = workspace.workspaceFileManager else { return } let resolvedTabs = editor .tabs - .compactMap({ fileManager.getFile($0.file.url.path(), createIfNotFound: true) }) - .map({ EditorInstance(file: $0) }) + .compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) }) + .map({ EditorInstance(workspace: workspace, file: $0) }) + + for tab in resolvedTabs { + try tab.file.loadCodeFile() + } + + editor.workspace = workspace editor.tabs = OrderedSet(resolvedTabs) + if let selectedTab = editor.selectedTab { - if let resolvedFile = fileManager.getFile(selectedTab.file.url.path(), createIfNotFound: true) { + if let resolvedFile = fileManager.getFile( + selectedTab.file.url.path(percentEncoded: false), + createIfNotFound: true + ) { editor.setSelectedTab(resolvedFile) } else { editor.setSelectedTab(nil) @@ -215,8 +225,12 @@ extension Editor: Codable { let id = try container.decode(UUID.self, forKey: .id) self.init( files: OrderedSet(fileURLs.map { CEWorkspaceFile(url: $0) }), - selectedTab: selectedTab == nil ? nil : EditorInstance(file: CEWorkspaceFile(url: selectedTab!)), - parent: nil + selectedTab: selectedTab == nil ? nil : EditorInstance( + workspace: nil, + file: CEWorkspaceFile(url: selectedTab!) + ), + parent: nil, + workspace: nil ) self.id = id } diff --git a/CodeEdit/Features/Editor/Models/EditorLayout.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/EditorLayout.swift rename to CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index 5c60da34b1..4e8eae3493 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -30,7 +30,7 @@ class EditorManager: ObservableObject { var activeEditorHistory: Deque<() -> Editor?> = [] /// notify listeners whenever tab selection changes on the active editor. - var tabBarTabIdSubject = PassthroughSubject() + var tabBarTabIdSubject = PassthroughSubject() var cancellable: AnyCancellable? // This caching mechanism is a temporary solution and is not optimized @@ -103,7 +103,7 @@ class EditorManager: ObservableObject { cancellable = nil cancellable = activeEditor.$selectedTab .sink { [weak self] tab in - self?.tabBarTabIdSubject.send(tab?.file.id) + self?.tabBarTabIdSubject.send(tab) } } diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift new file mode 100644 index 0000000000..4b375ac887 --- /dev/null +++ b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift @@ -0,0 +1,138 @@ +// +// EditorStateRestoration.swift +// CodeEdit +// +// Created by Khan Winter on 6/20/25. +// + +import Foundation +import GRDB +import CodeEditSourceEditor +import OSLog + +/// CodeEdit attempts to store and retrieve editor state for open tabs to restore the user's scroll position and +/// cursor positions between sessions. This class manages the storage mechanism to facilitate that feature. +/// +/// This creates a sqlite database in the application support directory named `editor-restoration.db`. +/// +/// To ensure we can query this quickly, this class is shared globally (to avoid having to use a database pool) and +/// all writes and reads are synchronous. +/// +/// # If changes are required +/// +/// Use the database migrator in the initializer for this class, see GRDB's documentation for adding a migration +/// version. **Do not ever** delete migration versions that have made it to a released version of CodeEdit. +final class EditorStateRestoration { + /// Optional here so we can gracefully catch errors. + /// The nice thing is this feature is optional in that if we don't have it available the user's experience is + /// degraded but not catastrophic. + static let shared: EditorStateRestoration? = try? EditorStateRestoration() + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "EditorStateRestoration" + ) + + struct StateRestorationRecord: Codable, TableRecord, FetchableRecord, PersistableRecord { + let uri: String + let data: Data + } + + struct StateRestorationData: Codable, Equatable { + // Cursor positions as range values (not row/column!) + let cursorPositions: [Range] + let scrollPositionX: Double + let scrollPositionY: Double + + var scrollPosition: CGPoint { + CGPoint(x: scrollPositionX, y: scrollPositionY) + } + + var editorCursorPositions: [CursorPosition] { + cursorPositions.map { CursorPosition(range: NSRange(start: $0.lowerBound, end: $0.upperBound)) } + } + + init(cursorPositions: [CursorPosition], scrollPosition: CGPoint) { + self.cursorPositions = cursorPositions + .compactMap { $0.range } + .map { $0.location..<($0.location + $0.length) } + self.scrollPositionX = scrollPosition.x + self.scrollPositionY = scrollPosition.y + } + } + + private var databaseQueue: DatabaseQueue? + private var databaseURL: URL + + /// Create a new editor restoration object. Will connect to or create a SQLite db. + /// - Parameter databaseURL: The database URL to use. Must point to a file, not a directory. If left `nil`, will + /// create a new database named `editor-restoration.db` in the application support + /// directory. + init(_ databaseURL: URL? = nil) throws { + self.databaseURL = databaseURL ?? FileManager.default + .homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) + .appending(path: "editor-restoration.db", directoryHint: .notDirectory) + try attemptMigration(retry: true) + } + + func attemptMigration(retry: Bool) throws { + do { + let databaseQueue = try DatabaseQueue(path: self.databaseURL.absolutePath, configuration: .init()) + + var migrator = DatabaseMigrator() + + migrator.registerMigration("Version 0") { + try $0.create(table: "stateRestorationRecord") { table in + table.column("uri", .text).primaryKey().notNull() + table.column("data", .blob).notNull() + } + } + + try migrator.migrate(databaseQueue) + self.databaseQueue = databaseQueue + } catch { + if retry { + // Try to delete the database on failure, might fix a corruption or version error. + try? FileManager.default.removeItem(at: databaseURL) + try attemptMigration(retry: false) + + return // Ignore the original error if we're retrying + } + Self.logger.error("Failed to start database connection: \(error)") + throw error + } + } + + /// Update saved restoration state of a document. + /// - Parameters: + /// - documentUrl: The URL of the document. + /// - data: The data to store for the file, retrieved using ``restorationState(for:)``. + func updateRestorationState(for documentUrl: URL, data: StateRestorationData) { + do { + let serializedData = try JSONEncoder().encode(data) + let dbRow = StateRestorationRecord(uri: documentUrl.absolutePath, data: serializedData) + try databaseQueue?.write { try dbRow.upsert($0) } + } catch { + Self.logger.error("Failed to save editor state: \(error)") + } + } + + /// Find the restoration state for a document. + /// - Parameter documentUrl: The URL of the document. + /// - Returns: Any data saved for this file. + func restorationState(for documentUrl: URL) -> StateRestorationData? { + do { + guard let row = try databaseQueue?.read({ + try StateRestorationRecord.fetchOne($0, key: documentUrl.absolutePath) + }) else { + return nil + } + let decodedData = try JSONDecoder().decode(StateRestorationData.self, from: row.data) + return decodedData + } catch { + Self.logger.error("Failed to find editor state for '\(documentUrl.absolutePath)': \(error)") + } + return nil + } +} diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift new file mode 100644 index 0000000000..a665c8991b --- /dev/null +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -0,0 +1,63 @@ +// +// UndoManagerRegistration.swift +// CodeEdit +// +// Created by Khan Winter on 6/27/25. +// + +import SwiftUI +import CodeEditTextView + +/// Very simple class for registering undo manager for files for a project session. This does not do any saving, it +/// just stores the undo managers in memory and retrieves them as necessary for files. +/// +/// Undo stacks aren't stored on `CEWorkspaceFile` or `CodeFileDocument` because: +/// - `CEWorkspaceFile` can be refreshed and reloaded at any point. +/// - `CodeFileDocument` is released once there are no editors viewing it. +/// Undo stacks need to be retained for the duration of a workspace session, enduring editor closes.. +final class UndoManagerRegistration: ObservableObject { + private var managerMap: [String: CEUndoManager] = [:] + + init() { } + + /// Find or create a new undo manager. + /// - Parameter file: The file to create for. + /// - Returns: The undo manager for the given file. + func manager(forFile file: CEWorkspaceFile) -> CEUndoManager { + manager(forFile: file.url) + } + + /// Find or create a new undo manager. + /// - Parameter path: The path of the file to create for. + /// - Returns: The undo manager for the given file. + func manager(forFile path: URL) -> CEUndoManager { + if let manager = managerMap[path.absolutePath] { + return manager + } else { + let newManager = CEUndoManager() + managerMap[path.absolutePath] = newManager + return newManager + } + } + + /// Find or create a new undo manager. + /// - Parameter path: The path of the file to create for. + /// - Returns: The undo manager for the given file. + func managerIfExists(forFile path: URL) -> CEUndoManager? { + managerMap[path.absolutePath] + } +} + +extension UndoManagerRegistration: CEWorkspaceFileManagerObserver { + /// Managers need to be cleared when the following is true: + /// - The file is not open in any editors + /// - The file is updated externally + /// + /// To handle this? + /// - When we receive a file update, if the file is not open in any editors we clear the undo stack + func fileManagerUpdated(updatedItems: Set) { + for file in updatedItems where file.fileDocument == nil { + managerMap.removeValue(forKey: file.url.absolutePath) + } + } +} diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift index b0f3a1baa8..5ba88387be 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift @@ -90,7 +90,7 @@ struct EditorTabView: View { // Only set the `selectedId` when they are not equal to avoid performance issue for now. editorManager.activeEditor = editor if editor.selectedTab?.file != tabFile { - let tabItem = EditorInstance(file: tabFile) + let tabItem = EditorInstance(workspace: workspace, file: tabFile) editor.setSelectedTab(tabFile) editor.clearFuture() editor.addToHistory(tabItem) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift index fbebd7c4b4..29e539cf14 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift @@ -137,7 +137,7 @@ struct EditorTabBarContextMenu: ViewModifier { } func moveToNewSplit(_ edge: Edge) { - let newEditor = Editor(files: [item]) + let newEditor = Editor(files: [item], workspace: workspace) splitEditor(edge, newEditor) tabs.closeTab(file: item) workspace.editorManager?.activeEditor = newEditor diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift index 156983061f..8333856606 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift @@ -22,6 +22,8 @@ struct EditorTabBarTrailingAccessories: View { @Environment(\.controlActiveState) private var activeState + @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var editor: Editor @@ -57,9 +59,9 @@ struct EditorTabBarTrailingAccessories: View { Toggle( "Wrap Lines", isOn: Binding( - get: { codeFile.wrapLines ?? wrapLinesToEditorWidth }, - set: { - codeFile.wrapLines = $0 + get: { [weak codeFile] in codeFile?.wrapLines ?? wrapLinesToEditorWidth }, + set: { [weak codeFile] in + codeFile?.wrapLines = $0 } ) ) @@ -97,7 +99,7 @@ struct EditorTabBarTrailingAccessories: View { func split(edge: Edge) { let newEditor: Editor if let tab = editor.selectedTab { - newEditor = .init(files: [tab], temporaryTab: tab) + newEditor = .init(files: [tab], temporaryTab: tab, workspace: workspace) } else { newEditor = .init() } diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 4d8a5e67cb..2aa04497b6 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -14,16 +14,14 @@ import Combine /// CodeFileView is just a wrapper of the `CodeEditor` dependency struct CodeFileView: View { + @ObservedObject private var editorInstance: EditorInstance @ObservedObject private var codeFile: CodeFileDocument - @State private var editorState: SourceEditorState - @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] - - @State private var highlightProviders: [any HighlightProviding] = [] + private var highlightProviders: [any HighlightProviding] = [] @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @@ -57,10 +55,16 @@ struct CodeFileView: View { var reformatAtColumn @AppSettings(\.textEditing.showReformattingGuide) var showReformattingGuide + @AppSettings(\.textEditing.invisibleCharacters) + var invisibleCharactersConfiguration + @AppSettings(\.textEditing.warningCharacters) + var warningCharacters @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject var undoRegistration: UndoManagerRegistration + @ObservedObject private var themeModel: ThemeModel = .shared @State private var treeSitter = TreeSitterClient() @@ -69,34 +73,35 @@ struct CodeFileView: View { private let isEditable: Bool - private let undoManager = CEUndoManager() - - init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { + init( + editorInstance: EditorInstance, + codeFile: CodeFileDocument, + textViewCoordinators: [TextViewCoordinator] = [], + isEditable: Bool = true + ) { + self._editorInstance = .init(wrappedValue: editorInstance) self._codeFile = .init(wrappedValue: codeFile) self.textViewCoordinators = textViewCoordinators + + [editorInstance.rangeTranslator] + [codeFile.contentCoordinator] - + [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 }) + + [codeFile.languageServerObjects.textCoordinator] self.isEditable = isEditable if let openOptions = codeFile.openOptions { codeFile.openOptions = nil - self.editorState = SourceEditorState(cursorPositions: openOptions.cursorPositions) - } else { - self.editorState = SourceEditorState() + editorInstance.cursorPositions = openOptions.cursorPositions } - updateHighlightProviders() + highlightProviders = [codeFile.languageServerObjects.highlightProvider] + [treeSitterClient] codeFile .contentCoordinator .textUpdatePublisher - .sink { _ in - codeFile.updateChangeCount(.changeDone) + .sink { [weak codeFile] _ in + codeFile?.updateChangeCount(.changeDone) } .store(in: &cancellables) - - codeFile.undoManager = self.undoManager } private var currentTheme: Theme { @@ -139,16 +144,34 @@ struct CodeFileView: View { showMinimap: showMinimap, showReformattingGuide: showReformattingGuide, showFoldingRibbon: showFoldingRibbon, - invisibleCharactersConfiguration: .empty, - warningCharacters: [] + invisibleCharactersConfiguration: invisibleCharactersConfiguration.textViewOption(), + warningCharacters: Set(warningCharacters.characters.keys) ) ), - state: $editorState, + state: Binding( + get: { + SourceEditorState( + cursorPositions: editorInstance.cursorPositions, + scrollPosition: editorInstance.scrollPosition, + findText: editorInstance.findText, + replaceText: editorInstance.replaceText + ) + }, + set: { newState in + editorInstance.cursorPositions = newState.cursorPositions ?? [] + editorInstance.scrollPosition = newState.scrollPosition + editorInstance.findText = newState.findText + editorInstance.findTextSubject.send(newState.findText) + editorInstance.replaceText = newState.replaceText + editorInstance.replaceTextSubject.send(newState.replaceText) + } + ), highlightProviders: highlightProviders, - undoManager: undoManager, + undoManager: undoRegistration.manager(forFile: editorInstance.file), coordinators: textViewCoordinators ) - .id(codeFile.fileURL) + // This view needs to refresh when the codefile changes. The file URL is too stable. + .id(ObjectIdentifier(codeFile)) .background { if colorScheme == .dark { EffectView(.underPageBackground) @@ -162,10 +185,6 @@ 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. @@ -188,12 +207,6 @@ 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 @@ -208,3 +221,23 @@ private extension SettingsData.TextEditingSettings.IndentOption { } } } + +private extension SettingsData.TextEditingSettings.InvisibleCharactersConfig { + func textViewOption() -> InvisibleCharactersConfiguration { + guard self.enabled else { return .empty } + var config = InvisibleCharactersConfiguration( + showSpaces: self.showSpaces, + showTabs: self.showTabs, + showLineEndings: self.showLineEndings + ) + + config.spaceReplacement = self.spaceReplacement + config.tabReplacement = self.tabReplacement + config.carriageReturnReplacement = self.carriageReturnReplacement + config.lineFeedReplacement = self.lineFeedReplacement + config.paragraphSeparatorReplacement = self.paragraphSeparatorReplacement + config.lineSeparatorReplacement = self.lineSeparatorReplacement + + return config + } +} diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e5d4f9ffc4..e4367dcc0a 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -19,12 +19,15 @@ struct EditorAreaFileView: View { @Environment(\.edgeInsets) private var edgeInsets + var editorInstance: EditorInstance var codeFile: CodeFileDocument - var textViewCoordinators: [TextViewCoordinator] = [] @ViewBuilder var editorAreaFileView: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: codeFile, textViewCoordinators: textViewCoordinators) + CodeFileView( + editorInstance: editorInstance, + codeFile: codeFile + ) } else { NonTextFileView(fileDocument: codeFile) .padding(.top, edgeInsets.top - 1.74) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 96ff8bf210..c617f8dd30 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -25,7 +25,7 @@ struct EditorAreaView: View { @EnvironmentObject private var editorManager: EditorManager - @State var codeFile: CodeFileDocument? + @State var codeFile: (() -> CodeFileDocument?)? @Environment(\.window.value) private var window: NSWindow? @@ -33,7 +33,9 @@ struct EditorAreaView: View { init(editor: Editor, focus: FocusState.Binding) { self.editor = editor self._focus = focus - self.codeFile = editor.selectedTab?.file.fileDocument + if let file = editor.selectedTab?.file.fileDocument { + self.codeFile = { [weak file] in file } + } } var body: some View { @@ -53,29 +55,26 @@ struct EditorAreaView: View { VStack { if let selected = editor.selectedTab { - if let codeFile = codeFile { - EditorAreaFileView( - codeFile: codeFile, - textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) - ) - .focusedObject(editor) - .transformEnvironment(\.edgeInsets) { insets in - insets.top += editorInsetAmount - } - .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - _ = handleDrop(providers: providers) - return true - } + if let codeFile = codeFile?() { + EditorAreaFileView(editorInstance: selected, codeFile: codeFile) + .focusedObject(editor) + .transformEnvironment(\.edgeInsets) { insets in + insets.top += editorInsetAmount + } + .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + _ = handleDrop(providers: providers) + return true + } } else { LoadingFileView(selected.file.name) .onAppear { if let file = selected.file.fileDocument { - self.codeFile = file + self.codeFile = { [weak file] in file } } } .onReceive(selected.file.fileDocumentPublisher) { latestValue in - self.codeFile = latestValue + self.codeFile = { [weak latestValue] in latestValue } } } @@ -96,6 +95,12 @@ struct EditorAreaView: View { .safeAreaInset(edge: .top, spacing: 0) { GeometryReader { geometry in let topSafeArea = geometry.safeAreaInsets.top + let fileBinding = Binding { + codeFile?() + } set: { newFile in + codeFile = { [weak newFile] in newFile } + } + VStack(spacing: 0) { if topSafeArea > 0 { Rectangle() @@ -104,7 +109,7 @@ struct EditorAreaView: View { .background(.clear) } if shouldShowTabBar { - EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: $codeFile) + EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: fileBinding) .id("TabBarView" + editor.id.uuidString) .environmentObject(editor) Divider() @@ -113,7 +118,7 @@ struct EditorAreaView: View { EditorJumpBarView( file: editor.selectedTab?.file, shouldShowTabBar: shouldShowTabBar, - codeFile: $codeFile + codeFile: fileBinding ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { editor?.openTab(file: newFile, at: index) @@ -141,7 +146,9 @@ struct EditorAreaView: View { } } .onChange(of: editor.selectedTab) { newValue in - codeFile = newValue?.file.fileDocument + if let file = newValue?.file.fileDocument { + codeFile = { [weak file] in file } + } } } @@ -155,9 +162,8 @@ struct EditorAreaView: View { DispatchQueue.main.async { let file = CEWorkspaceFile(url: url) - editor.openTab(file: file) editorManager.activeEditor = editor - focus = editor + editor.openTab(file: file) } } } diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift index 3bc2a16f8b..d53d1682f8 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift @@ -11,11 +11,24 @@ import SwiftUI /// View that fixes [#1158](https://github.com/CodeEditApp/CodeEdit/issues/1158) /// # Should **not** be used other than in a single file window. struct WindowCodeFileView: View { + @StateObject var editorInstance: EditorInstance + @StateObject var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() var codeFile: CodeFileDocument + init(codeFile: CodeFileDocument) { + self._editorInstance = .init( + wrappedValue: EditorInstance( + workspace: nil, + file: CEWorkspaceFile(url: codeFile.fileURL ?? URL(fileURLWithPath: "")) + ) + ) + self.codeFile = codeFile + } + var body: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: codeFile) + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + .environmentObject(undoRegistration) } else { NonTextFileView(fileDocument: codeFile) } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift index 0906bbcbfb..df1750159a 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -11,6 +11,7 @@ struct InternalDevelopmentInspectorView: View { var body: some View { Form { InternalDevelopmentNotificationsView() + InternalDevelopmentOutputView() } } } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift new file mode 100644 index 0000000000..6f86baee25 --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentOutputView.swift @@ -0,0 +1,54 @@ +// +// InternalDevelopmentOutputView.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct InternalDevelopmentOutputView: View { + var body: some View { + Section("Output Utility") { + Button("Error Log") { + pushLog(.error) + } + Button("Warning Log") { + pushLog(.warning) + } + Button("Info Log") { + pushLog(.info) + } + Button("Debug Log") { + pushLog(.debug) + } + } + + } + + func pushLog(_ level: UtilityAreaLogLevel) { + InternalDevelopmentOutputSource.shared.pushLog( + .init( + message: randomString(), + subsystem: "internal.development", + category: "Logs", + level: level + ) + ) + } + + func randomString() -> String { + let strings = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce molestie, dui et consectetur" + + "porttitor, orci lectus fermentum augue, eu faucibus lectus nisl id velit. Suspendisse in mi nunc. Aliquam" + + "non dolor eu eros mollis euismod. Praesent mollis mauris at ex dapibus ornare. Ut imperdiet" + + "finibus lacus ut aliquam. Vivamus semper, mauris in condimentum volutpat, quam erat eleifend ligula," + + "nec tincidunt sem ante et ex. Sed dui magna, placerat quis orci at, bibendum molestie massa. Maecenas" + + "velit nunc, vehicula eu venenatis vel, tincidunt id purus. Morbi eu dignissim arcu, sed ornare odio." + + "Nam vestibulum tempus nibh id finibus.").split(separator: " ") + let count = Int.random(in: 0..<25) + return (0..: TextViewCoord private var task: Task? weak var languageServer: LanguageServer? - var documentURI: String + var documentURI: String? /// Initializes a content coordinator, and begins an async stream of updates - init(documentURI: String, languageServer: LanguageServer) { + init(documentURI: String? = nil, languageServer: LanguageServer? = nil) { self.documentURI = documentURI self.languageServer = languageServer setUpUpdatesTask() } + func setUp(server: LanguageServer, document: DocumentType) { + languageServer = server + documentURI = document.languageServerURI + } + func setUpUpdatesTask() { task?.cancel() // Create this stream here so it's always set up when the text view is set up, rather than only once on init. @@ -76,7 +81,7 @@ class LSPContentCoordinator: TextViewCoord } func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { - guard let lspRange = editedRange else { + guard let lspRange = editedRange, let documentURI else { return } self.editedRange = nil diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift index 2e391fba4b..2fbfb8ea8a 100644 --- a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift @@ -32,9 +32,9 @@ final class SemanticTokenHighlightProvider< 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 var tokenMap: SemanticTokenMap? + private var documentURI: String? + weak var languageServer: LanguageServer? private weak var textView: TextView? private var lastEditCallback: EditCallback? @@ -45,13 +45,23 @@ final class SemanticTokenHighlightProvider< textView?.documentRange ?? .zero } - init(tokenMap: SemanticTokenMap, languageServer: LanguageServer, documentURI: String) { + init( + tokenMap: SemanticTokenMap? = nil, + languageServer: LanguageServer? = nil, + documentURI: String? = nil + ) { self.tokenMap = tokenMap self.languageServer = languageServer self.documentURI = documentURI self.storage = Storage() } + func setUp(server: LanguageServer, document: DocumentType) { + languageServer = server + documentURI = document.languageServerURI + tokenMap = server.highlightMap + } + // MARK: - Language Server Content Lifecycle /// Called when the language server finishes sending a document update. @@ -95,7 +105,8 @@ final class SemanticTokenHighlightProvider< textView: TextView, lastResultId: String ) async throws { - guard let response = try await languageServer.requestSemanticTokens( + guard let documentURI, + let response = try await languageServer.requestSemanticTokens( for: documentURI, previousResultId: lastResultId ) else { @@ -112,7 +123,7 @@ final class SemanticTokenHighlightProvider< /// 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 { + guard let documentURI, let response = try await languageServer.requestSemanticTokens(for: documentURI) else { return } await applyEntireResponse(response, callback: lastEditCallback) @@ -159,14 +170,27 @@ final class SemanticTokenHighlightProvider< return } - guard let lspRange = textView.lspRangeFrom(nsRange: range) else { + guard let lspRange = textView.lspRangeFrom(nsRange: range), let tokenMap 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 }) + .compactMap { highlightRange -> HighlightRange? in + // Filter out empty ranges + guard highlightRange.capture != nil || !highlightRange.modifiers.isEmpty, + // Clamp the highlight range to the queried range. + let intersection = highlightRange.range.intersection(range), + intersection.isEmpty == false else { + return nil + } + return HighlightRange( + range: intersection, + capture: highlightRange.capture, + modifiers: highlightRange.modifiers + ) + } completion(.success(highlights)) } } diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index be69c6647c..2c30e6935f 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -86,6 +86,7 @@ extension LanguageServer { switch resolveDocumentSyncKind() { case .full: guard let content = await getIsolatedDocumentContent(document) else { + logger.error("Failed to get isolated document content") return } let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content.string) @@ -107,7 +108,7 @@ extension LanguageServer { // 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() + try await document.languageServerObjects.highlightProvider.documentDidChange() } catch { logger.warning("closeDocument: Error \(error)") throw error @@ -128,10 +129,7 @@ extension LanguageServer { @MainActor private func updateIsolatedDocument(_ document: DocumentType) { - document.languageServerObjects = LanguageServerDocumentObjects( - textCoordinator: openFiles.contentCoordinator(for: document), - highlightProvider: openFiles.semanticHighlighter(for: document) - ) + document.languageServerObjects.setUp(server: self, document: document) } @MainActor diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index a7c48bb251..d1c87f0eeb 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -36,24 +36,32 @@ class LanguageServer { /// The configuration options this server supports. var serverCapabilities: ServerCapabilities + var logContainer: LanguageServerLogContainer + /// An instance of a language server, that may or may not be initialized private(set) var lspInstance: InitializingServer /// The path to the root of the project private(set) var rootPath: URL + /// The PID of the running language server process. + private(set) var pid: pid_t init( languageId: LanguageIdentifier, binary: LanguageServerBinary, lspInstance: InitializingServer, + lspPid: pid_t, serverCapabilities: ServerCapabilities, - rootPath: URL + rootPath: URL, + logContainer: LanguageServerLogContainer ) { self.languageId = languageId self.binary = binary self.lspInstance = lspInstance + self.pid = lspPid self.serverCapabilities = serverCapabilities self.rootPath = rootPath self.openFiles = LanguageServerFileMap() + self.logContainer = logContainer self.logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer.\(languageId.rawValue)" @@ -82,17 +90,26 @@ class LanguageServer { environment: binary.env ) + let logContainer = LanguageServerLogContainer(language: languageId) + let (connection, process) = try makeLocalServerConnection( + languageId: languageId, + executionParams: executionParams, + logContainer: logContainer + ) let server = InitializingServer( - server: try makeLocalServerConnection(languageId: languageId, executionParams: executionParams), + server: connection, initializeParamsProvider: getInitParams(workspacePath: workspacePath) ) - let capabilities = try await server.initializeIfNeeded() + let initializationResponse = try await server.initializeIfNeeded() + return LanguageServer( languageId: languageId, binary: binary, lspInstance: server, - serverCapabilities: capabilities, - rootPath: URL(filePath: workspacePath) + lspPid: process.processIdentifier, + serverCapabilities: initializationResponse.capabilities, + rootPath: URL(filePath: workspacePath), + logContainer: logContainer ) } @@ -105,16 +122,20 @@ class LanguageServer { /// - Returns: A new connection to the language server. static func makeLocalServerConnection( languageId: LanguageIdentifier, - executionParams: Process.ExecutionParameters - ) throws -> JSONRPCServerConnection { + executionParams: Process.ExecutionParameters, + logContainer: LanguageServerLogContainer + ) throws -> (connection: JSONRPCServerConnection, process: Process) { do { - let channel = try DataChannel.localProcessChannel( + let (channel, process) = try DataChannel.localProcessChannel( parameters: executionParams, - terminationHandler: { + terminationHandler: { [weak logContainer] in logger.debug("Terminated data channel for \(languageId.rawValue)") + logContainer?.appendLog( + LogMessageParams(type: .error, message: "Data Channel Terminated Unexpectedly") + ) } ) - return JSONRPCServerConnection(dataChannel: channel) + return (JSONRPCServerConnection(dataChannel: channel), process) } catch { logger.warning("Failed to initialize data channel for \(languageId.rawValue)") throw error @@ -232,10 +253,14 @@ class LanguageServer { // swiftlint:enable function_body_length } + // MARK: - Shutdown + /// Shuts down the language server and exits it. public func shutdown() async throws { self.logger.info("Shutting down language server") - try await lspInstance.shutdownAndExit() + try await withTimeout(duration: .seconds(1.0)) { + try await self.lspInstance.shutdownAndExit() + } } } diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index fd71a06b7a..ac218acc20 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -16,8 +16,6 @@ class LanguageServerFileMap { private struct DocumentObject { let uri: String var documentVersion: Int - var contentCoordinator: LSPContentCoordinator - var semanticHighlighter: HighlightProviderType? } private var trackedDocuments: NSMapTable @@ -32,24 +30,7 @@ class LanguageServerFileMap { func addDocument(_ document: DocumentType, for server: LanguageServer) { guard let uri = document.languageServerURI else { return } trackedDocuments.setObject(document, forKey: uri as NSString) - var docData = DocumentObject( - uri: uri, - documentVersion: 0, - contentCoordinator: LSPContentCoordinator( - documentURI: uri, - languageServer: server - ), - semanticHighlighter: nil - ) - - if let tokenMap = server.highlightMap { - docData.semanticHighlighter = HighlightProviderType( - tokenMap: tokenMap, - languageServer: server, - documentURI: uri - ) - } - + let docData = DocumentObject(uri: uri, documentVersion: 0) trackedDocumentData[uri] = docData } @@ -87,22 +68,4 @@ class LanguageServerFileMap { func documentVersion(for uri: DocumentUri) -> Int? { return trackedDocumentData[uri]?.documentVersion } - - // MARK: - Content Coordinator - - func contentCoordinator(for document: DocumentType) -> LSPContentCoordinator? { - guard let uri = document.languageServerURI else { return nil } - return contentCoordinator(for: uri) - } - - 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 index 8b4b09a47d..2953d08fc2 100644 --- a/CodeEdit/Features/LSP/LanguageServerDocument.swift +++ b/CodeEdit/Features/LSP/LanguageServerDocument.swift @@ -10,8 +10,15 @@ import CodeEditLanguages /// A set of properties a language server sets when a document is registered. struct LanguageServerDocumentObjects { - var textCoordinator: LSPContentCoordinator? - var highlightProvider: SemanticTokenHighlightProvider? + var textCoordinator: LSPContentCoordinator = LSPContentCoordinator() + // swiftlint:disable:next line_length + var highlightProvider: SemanticTokenHighlightProvider = SemanticTokenHighlightProvider() + + @MainActor + func setUp(server: LanguageServer, document: DocumentType) { + textCoordinator.setUp(server: server, document: document) + highlightProvider.setUp(server: server, document: document) + } } /// A protocol that allows a language server to register objects on a text document. diff --git a/CodeEdit/Features/LSP/Registry/InstallationMethod.swift b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift new file mode 100644 index 0000000000..0ef71be162 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift @@ -0,0 +1,53 @@ +// +// 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/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift new file mode 100644 index 0000000000..b1f6909607 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift @@ -0,0 +1,185 @@ +// +// InstallationQueueManager.swift +// CodeEdit +// +// Created by Abe Malla on 3/13/25. +// + +import Foundation + +/// A class to manage queued installations of language servers +final class InstallationQueueManager { + static let shared: InstallationQueueManager = .init() + + /// The maximum number of concurrent installations allowed + private let maxConcurrentInstallations: Int = 2 + /// Queue of pending installations + private var installationQueue: [(RegistryItem, (Result) -> Void)] = [] + /// Currently running installations + private var runningInstallations: Set = [] + /// Installation status dictionary + private var installationStatus: [String: PackageInstallationStatus] = [:] + + /// Add a package to the installation queue + func queueInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { + // If we're already at max capacity and this isn't already running, mark as queued + if runningInstallations.count >= maxConcurrentInstallations && !runningInstallations.contains(package.name) { + installationStatus[package.name] = .queued + installationQueue.append((package, completion)) + + // Notify UI that package is queued + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.queued] + ) + } + } else { + startInstallation(package: package, completion: completion) + } + } + + /// Starts the actual installation process for a package + private func startInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { + installationStatus[package.name] = .installing + runningInstallations.insert(package.name) + + // Notify UI that installation is now in progress + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installing] + ) + } + + Task { + do { + try await RegistryManager.shared.installPackage(package: package) + + // Notify UI that installation is complete + installationStatus[package.name] = .installed + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installed] + ) + completion(.success(())) + } + } catch { + // Notify UI that installation failed + installationStatus[package.name] = .failed(error) + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.failed(error)] + ) + completion(.failure(error)) + } + } + + runningInstallations.remove(package.name) + processNextInstallations() + } + } + + /// Process next installations from the queue if possible + private func processNextInstallations() { + while runningInstallations.count < maxConcurrentInstallations && !installationQueue.isEmpty { + let (package, completion) = installationQueue.removeFirst() + if runningInstallations.contains(package.name) { + continue + } + + startInstallation(package: package, completion: completion) + } + } + + /// Cancel an installation if it's in the queue + func cancelInstallation(packageName: String) { + installationQueue.removeAll { $0.0.name == packageName } + installationStatus[packageName] = .cancelled + runningInstallations.remove(packageName) + + // Notify UI that installation was cancelled + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": packageName, "status": PackageInstallationStatus.cancelled] + ) + } + processNextInstallations() + } + + /// Get the current status of an installation + func getInstallationStatus(packageName: String) -> PackageInstallationStatus { + return installationStatus[packageName] ?? .notQueued + } + + /// Cleans up installation status by removing completed or failed installations + func cleanUpInstallationStatus() { + let statusKeys = installationStatus.keys.map { $0 } + for packageName in statusKeys { + if let status = installationStatus[packageName] { + switch status { + case .installed, .failed, .cancelled: + installationStatus.removeValue(forKey: packageName) + case .queued, .installing, .notQueued: + break + } + } + } + + // If an item is in runningInstallations but not in an active state in the status dictionary, + // it might be a stale reference + let currentRunning = runningInstallations.map { $0 } + for packageName in currentRunning { + let status = installationStatus[packageName] + if status != .installing { + runningInstallations.remove(packageName) + } + } + + // Check for orphaned queue items + installationQueue = installationQueue.filter { item, _ in + return installationStatus[item.name] == .queued + } + } +} + +/// Status of a package installation +enum PackageInstallationStatus: Equatable { + case notQueued + case queued + case installing + case installed + case failed(Error) + case cancelled + + static func == (lhs: PackageInstallationStatus, rhs: PackageInstallationStatus) -> Bool { + switch (lhs, rhs) { + case (.notQueued, .notQueued): + return true + case (.queued, .queued): + return true + case (.installing, .installing): + return true + case (.installed, .installed): + return true + case (.cancelled, .cancelled): + return true + case (.failed, .failed): + return true + default: + return false + } + } +} + +extension Notification.Name { + static let installationStatusChanged = Notification.Name("installationStatusChanged") +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerError.swift b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift new file mode 100644 index 0000000000..fd92c26308 --- /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 new file mode 100644 index 0000000000..1077cff1b2 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -0,0 +1,96 @@ +// +// PackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +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 +} + +extension PackageManagerProtocol { + /// Creates the directory for the language server to be installed in + func createDirectoryStructure(for packagePath: URL) throws { + let decodedPath = packagePath.path.removingPercentEncoding ?? packagePath.path + if !FileManager.default.fileExists(atPath: decodedPath) { + try FileManager.default.createDirectory( + at: packagePath, + withIntermediateDirectories: true, + attributes: nil + ) + } + } + + /// Executes commands in the specified directory + func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { + return try await runCommand("cd \"\(packagePath)\" && \(args.joined(separator: " "))") + } + + /// Runs a shell command and returns output + func runCommand(_ command: String) async throws -> [String] { + var output: [String] = [] + for try await line in shellClient.runAsync(command) { + output.append(line) + } + return output + } +} + +/// Generic package source information that applies to all installation methods. +/// Takes all the necessary information from `RegistryItem`. +struct PackageSource: Equatable, Codable { + /// The raw source ID string from the registry + let sourceId: String + /// The type of the package manager + let type: PackageManagerType + /// Package name + let pkgName: String + /// The name in the registry.json file. Used for the folder name when saved. + let entryName: String + /// Package version + let version: String + /// URL for repository or download link + let repositoryUrl: String? + /// Git reference type if this is a git based package + let gitReference: GitReference? + /// Additional possible options + var options: [String: String] + + init( + sourceId: String, + type: PackageManagerType, + pkgName: String, + entryName: String, + version: String, + repositoryUrl: String? = nil, + gitReference: GitReference? = nil, + options: [String: String] = [:] + ) { + self.sourceId = sourceId + self.type = type + self.pkgName = pkgName + self.entryName = entryName + self.version = version + self.repositoryUrl = repositoryUrl + self.gitReference = gitReference + self.options = options + } + + enum GitReference: Equatable, Codable { + case tag(String) + case revision(String) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerType.swift b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift new file mode 100644 index 0000000000..2a3982f128 --- /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 new file mode 100644 index 0000000000..f03ee6a2d2 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -0,0 +1,84 @@ +// +// CargoPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class CargoPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + do { + try createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + var 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]) + } + } else { + cargoArgs.append("\(source.pkgName)@\(source.version)") + } + + if let features = source.options["features"] { + cargoArgs.append(contentsOf: ["--features", features]) + } + if source.options["locked"] == "true" { + cargoArgs.append("--locked") + } + + _ = try await executeInDirectory(in: packagePath.path, cargoArgs) + } catch { + throw error + } + } + + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("cargo --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.starts(with: "cargo") + } catch { + return false + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift new file mode 100644 index 0000000000..c91a8d1a73 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -0,0 +1,115 @@ +// +// GithubPackageManager.swift +// LSPInstallTest +// +// Created by Abe Malla on 3/10/25. +// + +import Foundation + +final class GithubPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + switch method { + case let .binaryDownload(source, url): + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + try await downloadBinary(source, url) + + case let .sourceBuild(source, command): + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + try await installFromSource(source, command) + + case .standardPackage, .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("git --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.contains("git version") + } catch { + return false + } + } + + private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "HTTP error", code: (response as? HTTPURLResponse)?.statusCode ?? -1) + ) + } + + let fileName = url.lastPathComponent + let downloadPath = installationDirectory.appending(path: source.entryName) + let packagePath = downloadPath.appending(path: fileName) + + do { + try data.write(to: packagePath, options: .atomic) + } catch { + throw RegistryManagerError.downloadFailed( + url: url, + error: error + ) + } + + if !FileManager.default.fileExists(atPath: packagePath.path) { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "Could not download package", code: -1) + ) + } + + if fileName.hasSuffix(".tar") || fileName.hasSuffix(".zip") { + try FileManager.default.unzipItem(at: packagePath, to: downloadPath) + } + } + + private func installFromSource(_ source: PackageSource, _ command: String) async throws { + let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) + do { + guard let repoURL = source.repositoryUrl else { + throw PackageManagerError.invalidConfiguration + } + + _ = try await executeInDirectory(in: installPath.path, ["git", "clone", repoURL]) + let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) + _ = try await executeInDirectory(in: repoPath.path, [command]) + } catch { + throw PackageManagerError.installationFailed("Source build failed.") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift new file mode 100644 index 0000000000..e30e02c3d0 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -0,0 +1,154 @@ +// +// GolangPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class GolangPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + + // For Go, we need to set up a proper module structure + let goModPath = packagePath.appending(path: "go.mod") + if !FileManager.default.fileExists(atPath: goModPath.path) { + let moduleName = "codeedit.temp/placeholder" + _ = try await executeInDirectory( + in: packagePath.path, ["go mod init \(moduleName)"] + ) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + let gobinPath = packagePath.appending(path: "bin", directoryHint: .isDirectory).path + var goInstallCommand = ["env", "GOBIN=\(gobinPath)", "go", "install"] + + goInstallCommand.append(getGoInstallCommand(source)) + _ = try await executeInDirectory(in: packagePath.path, goInstallCommand) + + // If there's a subpath, build the binary + if let subpath = source.options["subpath"] { + let binPath = packagePath.appending(path: "bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + let binaryName = subpath.components(separatedBy: "/").last ?? + source.pkgName.components(separatedBy: "/").last ?? source.pkgName + let buildArgs = ["go", "build", "-o", "bin/\(binaryName)"] + + // If source.pkgName includes the full import path (like github.com/owner/repo) + if source.pkgName.contains("/") { + _ = try await executeInDirectory( + in: packagePath.path, buildArgs + ["\(source.pkgName)/\(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)\"") + } + } catch { + try? cleanupFailedInstallation(packagePath: packagePath) + throw PackageManagerError.installationFailed(error.localizedDescription) + } + } + + /// Get the binary path for a Go package + func getBinaryPath(for package: String) -> String { + let binPath = installationDirectory.appending(path: package).appending(path: "bin") + let binaryName = package.components(separatedBy: "/").last ?? package + let specificBinPath = binPath.appending(path: binaryName).path + if FileManager.default.fileExists(atPath: specificBinPath) { + return specificBinPath + } + return binPath.path + } + + /// Check if go is installed + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("go version") + let versionPattern = #"go version go\d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.range(of: versionPattern, options: .regularExpression) != nil + } catch { + return false + } + } + + // MARK: - Helper methods + + /// Clean up after a failed installation + private func cleanupFailedInstallation(packagePath: URL) throws { + let goSumPath = packagePath.appending(path: "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) + } + } + + 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.pkgName + 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.pkgName)@\(source.version)" + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift new file mode 100644 index 0000000000..e74470bb28 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -0,0 +1,136 @@ +// +// NPMPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +final class NPMPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + /// Initializes the npm project if not already initialized + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + // Clean existing files + let pkgJson = packagePath.appending(path: "package.json") + if FileManager.default.fileExists(atPath: pkgJson.path) { + try FileManager.default.removeItem(at: pkgJson) + } + let pkgLockJson = packagePath.appending(path: "package-lock.json") + if FileManager.default.fileExists(atPath: pkgLockJson.path) { + try FileManager.default.removeItem(at: pkgLockJson) + } + + // Init npm directory with .npmrc file + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["npm init --yes --scope=codeedit"] + ) + + let npmrcPath = packagePath.appending(path: ".npmrc") + if !FileManager.default.fileExists(atPath: npmrcPath.path) { + try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + /// Install a package using the new installation method + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + var installArgs = ["npm", "install", "\(source.pkgName)@\(source.version)"] + if let dev = source.options["dev"], dev.lowercased() == "true" { + installArgs.append("--save-dev") + } + if let extraPackages = source.options["extraPackages"]?.split(separator: ",") { + for pkg in extraPackages { + installArgs.append(String(pkg).trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try verifyInstallation(folderName: source.entryName, package: source.pkgName, version: source.version) + } catch { + let nodeModulesPath = packagePath.appending(path: "node_modules").path + try? FileManager.default.removeItem(atPath: nodeModulesPath) + throw error + } + } + + /// Get the path to the binary + func getBinaryPath(for package: String) -> String { + let binDirectory = installationDirectory + .appending(path: package) + .appending(path: "node_modules") + .appending(path: ".bin") + return binDirectory.appending(path: package).path + } + + /// Checks if npm is installed + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("npm --version") + let versionPattern = #"^\d+\.\d+\.\d+$"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.range(of: versionPattern, options: .regularExpression) != nil + } catch { + return false + } + } + + /// Verify the installation was successful + private func verifyInstallation(folderName: String, package: String, version: String) throws { + let packagePath = installationDirectory.appending(path: folderName) + let packageJsonPath = packagePath.appending(path: "package.json").path + + // Verify package.json contains the installed package + guard let packageJsonData = FileManager.default.contents(atPath: packageJsonPath), + let packageJson = try? JSONSerialization.jsonObject(with: packageJsonData, options: []), + let packageDict = packageJson as? [String: Any], + let dependencies = packageDict["dependencies"] as? [String: String], + let installedVersion = dependencies[package] else { + throw PackageManagerError.installationFailed("Package not found in package.json") + } + + // Verify installed version matches requested version + let normalizedInstalledVersion = installedVersion.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + let normalizedRequestedVersion = version.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + if normalizedInstalledVersion != normalizedRequestedVersion && + !installedVersion.contains(normalizedRequestedVersion) { + throw PackageManagerError.installationFailed( + "Version mismatch: Expected \(version), but found \(installedVersion)" + ) + } + + // Verify the package exists in node_modules + let packageDirectory = packagePath + .appending(path: "node_modules") + .appending(path: package) + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + throw PackageManagerError.installationFailed("Package not found in node_modules") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift new file mode 100644 index 0000000000..4eb5ff2624 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -0,0 +1,147 @@ +// +// PipPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class PipPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["python -m venv venv"] + ) + + let requirementsPath = packagePath.appending(path: "requirements.txt") + if !FileManager.default.fileExists(atPath: requirementsPath.path) { + try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + let pipCommand = getPipCommand(in: packagePath) + var installArgs = [pipCommand, "install"] + + if source.version.lowercased() != "latest" { + installArgs.append("\(source.pkgName)==\(source.version)") + } else { + installArgs.append(source.pkgName) + } + + let extras = source.options["extra"] + if let extras { + if let lastIndex = installArgs.indices.last { + installArgs[lastIndex] += "[\(extras)]" + } + } + + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try await updateRequirements(packagePath: packagePath) + try await verifyInstallation(packagePath: packagePath, package: source.pkgName) + } catch { + throw error + } + } + + /// Get the binary path for a Python package + func getBinaryPath(for package: String) -> String { + let packagePath = installationDirectory.appending(path: package) + let customBinPath = packagePath.appending(path: "bin").appending(path: package).path + if FileManager.default.fileExists(atPath: customBinPath) { + return customBinPath + } + return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path + } + + func isInstalled() async -> Bool { + let pipCommands = ["pip3 --version", "python3 -m pip --version"] + for command in pipCommands { + do { + let versionOutput = try await runCommand(command) + let versionPattern = #"pip \d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + if output.range(of: versionPattern, options: .regularExpression) != nil { + return true + } + } catch { + continue + } + } + return false + } + + // MARK: - Helper methods + + private func getPipCommand(in packagePath: URL) -> String { + let venvPip = "venv/bin/pip" + return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) + ? venvPip + : "python -m pip" + } + + /// Update the requirements.txt file with the installed package and extras + private func updateRequirements(packagePath: URL) async throws { + let pipCommand = getPipCommand(in: packagePath) + let requirementsPath = packagePath.appending(path: "requirements.txt") + + let freezeOutput = try await executeInDirectory( + in: packagePath.path, + ["\(pipCommand)", "freeze"] + ) + + let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" + try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) + } + + private func verifyInstallation(packagePath: URL, package: String) async throws { + let pipCommand = getPipCommand(in: packagePath) + let output = try await executeInDirectory( + in: packagePath.path, ["\(pipCommand)", "list", "--format=freeze"] + ) + + // Normalize package names for comparison + let normalizedPackageHyphen = package.replacingOccurrences(of: "_", with: "-").lowercased() + let normalizedPackageUnderscore = package.replacingOccurrences(of: "-", with: "_").lowercased() + + // Check if the package name appears in requirements.txt + let installedPackages = output.map { line in + line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) + } + let packageFound = installedPackages.contains { installedPackage in + installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore + } + + guard packageFound else { + throw PackageManagerError.installationFailed("Package \(package) not found in pip list") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift new file mode 100644 index 0000000000..0b81d6a97a --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -0,0 +1,73 @@ +// +// 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.removingPercentEncoding ?? 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: PackageSource.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, + pkgName: packageName, + entryName: entry.name, + 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 0000000000..1c2c7734af --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -0,0 +1,63 @@ +// +// 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.removingPercentEncoding ?? 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: PackageSource.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, + pkgName: packageName, + entryName: entry.name, + 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 0000000000..d75bf49700 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -0,0 +1,75 @@ +// +// 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.removingPercentEncoding ?? 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: PackageSource.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, + pkgName: packageName, + entryName: entry.name, + 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 0000000000..b5a63bb9e9 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -0,0 +1,88 @@ +// +// 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.removingPercentEncoding ?? 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) = parseNPMPackageNameAndVersion(packageVersion) + + // Parse parameters as options + var options: [String: String] = ["buildTool": "npm"] + var repositoryUrl: String? + var gitReference: PackageSource.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, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseNPMPackageNameAndVersion(_ 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: PackageSource.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, + pkgName: packageName, + entryName: entry.name, + 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 0000000000..666f05a73b --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -0,0 +1,98 @@ +// +// 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: PackageSource.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 + } + + var source = PackageSource( + sourceId: sourceId, + type: isSourceBuild ? .sourceBuild : .github, + pkgName: packageName, + entryName: entry.name, + 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/RegistryItemTemplateParser.swift b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift new file mode 100644 index 0000000000..16d171b62f --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift @@ -0,0 +1,133 @@ +// +// 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 item 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+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift new file mode 100644 index 0000000000..9a3b5621de --- /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 { + logger.error("Error updating: \(error)") + handleUpdateError(RegistryManagerError.writeFailed(error: error)) + } + } + + func handleUpdateError(_ error: Error) { + if let regError = error as? RegistryManagerError { + switch regError { + case .invalidResponse(let statusCode): + logger.error("Invalid response received: \(statusCode)") + case let .downloadFailed(url, error): + logger.error("Download failed for \(url.absoluteString): \(error.localizedDescription)") + case let .maxRetriesExceeded(url, error): + logger.error("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") + case let .writeFailed(error): + logger.error("Failed to write files to disk: \(error.localizedDescription)") + } + } else { + logger.error("Unexpected registry error: \(error.localizedDescription)") + } + } + + /// Attempts downloading from `url`, with error handling and a retry policy + 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 + 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 new file mode 100644 index 0000000000..06bddba5a0 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift @@ -0,0 +1,30 @@ +// +// RegistryManager+Parsing.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +import Foundation + +extension RegistryManager { + /// Parse a registry entry and create the appropriate installation method + internal static func parseRegistryEntry(_ entry: RegistryItem) -> InstallationMethod { + let sourceId = entry.source.id + if sourceId.hasPrefix("pkg:cargo/") { + return PackageSourceParser.parseCargoPackage(entry) + } else if sourceId.hasPrefix("pkg:npm/") { + return PackageSourceParser.parseNpmPackage(entry) + } else if sourceId.hasPrefix("pkg:pypi/") { + return PackageSourceParser.parsePythonPackage(entry) + } else if sourceId.hasPrefix("pkg:gem/") { + return PackageSourceParser.parseRubyGem(entry) + } else if sourceId.hasPrefix("pkg:golang/") { + return PackageSourceParser.parseGolangPackage(entry) + } else if sourceId.hasPrefix("pkg:github/") { + return PackageSourceParser.parseGithubPackage(entry) + } else { + return .unknown + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift new file mode 100644 index 0000000000..a2d0945c5a --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -0,0 +1,223 @@ +// +// Registry.swift +// CodeEdit +// +// Created by Abe Malla on 1/29/25. +// + +import OSLog +import Foundation +import ZIPFoundation + +@MainActor +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 + 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 + let checksumURL = URL( + string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" + )! + + /// Reference to cached registry data. Will be removed from memory after a certain amount of time. + private var cachedRegistry: CachedRegistry? + /// Timer to clear expired cache + private var cleanupTimer: Timer? + /// Public access to registry items with cache management + public var registryItems: [RegistryItem] { + if let cache = cachedRegistry, !cache.isExpired { + return cache.items + } + + // Load the registry items from disk again after cache expires + if let items = loadItemsFromDisk() { + cachedRegistry = CachedRegistry(items: items) + + // Set up timer to clear the cache after expiration + cleanupTimer?.invalidate() + cleanupTimer = Timer.scheduledTimer( + withTimeInterval: CachedRegistry.expirationInterval, repeats: false + ) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + self.cachedRegistry = nil + self.cleanupTimer = nil + } + } + return items + } + + return [] + } + + @AppSettings(\.languageServers.installedLanguageServers) + var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + + deinit { + cleanupTimer?.invalidate() + } + + func installPackage(package entry: RegistryItem) async throws { + return try await Task.detached(priority: .userInitiated) { () in + let method = await Self.parseRegistryEntry(entry) + guard let manager = await self.createPackageManager(for: method) else { + throw PackageManagerError.invalidConfiguration + } + + // Add to activity viewer + let activityTitle = "\(entry.name)\("@" + (method.version ?? "latest"))" + await MainActor.run { + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": entry.name, + "action": "create", + "title": "Installing \(activityTitle)" + ] + ) + } + + do { + try await manager.install(method: method) + } catch { + await MainActor.run { + Self.updateActivityViewer(entry.name, activityTitle, fail: true) + } + // Throw error again so the UI can catch it + throw error + } + + // 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 + } + + @MainActor + func removeLanguageServer(packageName: String) async throws { + let packageName = packageName.removingPercentEncoding ?? packageName + let packageDirectory = installPath.appending(path: packageName) + + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + installedLanguageServers.removeValue(forKey: packageName) + return + } + + // Add to activity viewer + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": packageName, + "action": "create", + "title": "Removing \(packageName)" + ] + ) + + do { + try await Task.detached(priority: .userInitiated) { + try FileManager.default.removeItem(at: packageDirectory) + }.value + installedLanguageServers.removeValue(forKey: packageName) + } catch { + throw error + } + } + + /// Updates the activity viewer with the status of the language server installation + @MainActor + private static func updateActivityViewer( + _ id: String, + _ activityName: String, + fail failed: Bool + ) { + if failed { + 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( + 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, + ] + ) + } + } + + /// Create the appropriate package manager for the given installation method + func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { + switch method.packageManagerType { + case .npm: + return NPMPackageManager(installationDirectory: installPath) + case .cargo: + return CargoPackageManager(installationDirectory: installPath) + case .pip: + return PipPackageManager(installationDirectory: installPath) + case .golang: + return GolangPackageManager(installationDirectory: installPath) + case .github, .sourceBuild: + return GithubPackageManager(installationDirectory: installPath) + case .nuget, .opam, .gem, .composer: + // TODO: IMPLEMENT OTHER PACKAGE MANAGERS + return nil + case .none: + return nil + } + } +} + +/// `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/RegistryManagerError.swift b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift new file mode 100644 index 0000000000..ac0da00b38 --- /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/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift new file mode 100644 index 0000000000..f610d349f6 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -0,0 +1,257 @@ +// +// 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 build: BuildContainer? + 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() + } + } + + func getDarwinFileName() -> String? { + switch self { + case .single(let asset): + if asset.target.isDarwinTarget() { + return asset.file + } + + case .multiple(let assets): + for asset in assets where asset.target.isDarwinTarget() { + return asset.file + } + + case .simpleFile(let fileName): + return fileName + + case .none: + return nil + } + return nil + } + } + + 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() + } + } + + func getUnixBuildCommand() -> String? { + switch self { + case .single(let build): + return build.run + case .multiple(let builds): + for build in builds { + guard let target = build.target else { continue } + if target.isDarwinTarget() { + return build.run + } + } + case .none: + return nil + } + return nil + } + } + + struct Build: Codable { + let target: Target? + let run: String + let env: [String: String]? + let bin: BinContainer? + } + + struct Asset: Codable { + let target: Target + let file: String? + let bin: BinContainer? + + 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]) + + 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 isDarwinTarget() -> Bool { + switch self { + case .single(let value): +#if arch(arm64) + return value == "darwin" || value == "darwin_arm64" || value == "unix" +#else + return value == "darwin" || value == "darwin_x64" || value == "unix" +#endif + case .multiple(let values): +#if arch(arm64) + return values.contains("darwin") || + values.contains("darwin_arm64") || + values.contains("unix") +#else + 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.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) + } + } + } + + struct VersionOverride: Codable { + let constraint: String + let id: String + let asset: AssetContainer? + } + } + + /// Serializes back to JSON format + func toDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + let jsonObject = try JSONSerialization.jsonObject(with: data) + guard let dictionary = jsonObject as? [String: Any] else { + throw NSError(domain: "ConversionError", code: 1) + } + return dictionary + } +} diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index b4baa73bb9..41d61f72ea 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -5,6 +5,7 @@ // Created by Abe Malla on 6/1/24. // +import Foundation import LanguageClient import LanguageServerProtocol @@ -32,64 +33,67 @@ extension LSPService { } private func handleEvent(_ event: ServerEvent, for key: ClientKey) { - // TODO: Handle Events -// switch event { -// case let .request(id, request): -// print("Request ID: \(id) for \(key.languageId.rawValue)") -// handleRequest(request) -// case let .notification(notification): -// handleNotification(notification) -// case let .error(error): -// print("Error from EventStream for \(key.languageId.rawValue): \(error)") -// } + guard let client = languageClient(for: key.languageId, workspacePath: key.workspacePath) else { + return + } + + switch event { + case let .request(_, request): + handleRequest(request, client: client) + case let .notification(notification): + handleNotification(notification, client: client) + case let .error(error): + logger.warning("Error from server \(key.languageId.rawValue, privacy: .public): \(error)") + } } - private func handleRequest(_ request: ServerRequest) { + private func handleRequest(_ request: ServerRequest, client: LanguageServerType) { // TODO: Handle Requests -// switch request { -// case let .workspaceConfiguration(params, _): -// print("workspaceConfiguration: \(params)") -// case let .workspaceFolders(handler): -// print("workspaceFolders: \(String(describing: handler))") -// case let .workspaceApplyEdit(params, _): -// print("workspaceApplyEdit: \(params)") + switch request { + // case let .workspaceConfiguration(params, _): + // print("workspaceConfiguration: \(params)") + // case let .workspaceFolders(handler): + // print("workspaceFolders: \(String(describing: handler))") + // case let .workspaceApplyEdit(params, _): + // print("workspaceApplyEdit: \(params)") // case let .clientRegisterCapability(params, _): // print("clientRegisterCapability: \(params)") // case let .clientUnregisterCapability(params, _): // print("clientUnregisterCapability: \(params)") -// case let .workspaceCodeLensRefresh(handler): -// print("workspaceCodeLensRefresh: \(String(describing: handler))") + // case let .workspaceCodeLensRefresh(handler): + // print("workspaceCodeLensRefresh: \(String(describing: handler))") // case let .workspaceSemanticTokenRefresh(handler): -// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") -// case let .windowShowMessageRequest(params, _): -// print("windowShowMessageRequest: \(params)") -// case let .windowShowDocument(params, _): -// print("windowShowDocument: \(params)") -// case let .windowWorkDoneProgressCreate(params, _): -// print("windowWorkDoneProgressCreate: \(params)") -// -// default: -// print() -// } +// print("Refresh semantic tokens!", handler) + // case let .windowShowMessageRequest(params, _): + // print("windowShowMessageRequest: \(params)") + // case let .windowShowDocument(params, _): + // print("windowShowDocument: \(params)") + // case let .windowWorkDoneProgressCreate(params, _): + // print("windowWorkDoneProgressCreate: \(params)") + default: + return + } } - private func handleNotification(_ notification: ServerNotification) { + private func handleNotification(_ notification: ServerNotification, client: LanguageServerType) { // TODO: Handle Notifications -// switch notification { -// case let .windowLogMessage(params): -// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") + switch notification { + case let .windowLogMessage(message): + client.logContainer.appendLog(message) // case let .windowShowMessage(params): // print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") -// case let .textDocumentPublishDiagnostics(params): -// print("textDocumentPublishDiagnostics: \(params)") + // case let .textDocumentPublishDiagnostics(params): + // print("textDocumentPublishDiagnostics: \(params)") // case let .telemetryEvent(params): // print("telemetryEvent: \(params)") -// case let .protocolCancelRequest(params): -// print("protocolCancelRequest: \(params)") + // case let .protocolCancelRequest(params): + // print("protocolCancelRequest: \(params)") // case let .protocolProgress(params): // print("protocolProgress: \(params)") // case let .protocolLogTrace(params): // print("protocolLogTrace: \(params)") -// } + default: + return + } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index df74fb1399..559413ec29 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -7,6 +7,7 @@ import os.log import JSONRPC +import SwiftUI import Foundation import LanguageClient import LanguageServerProtocol @@ -114,7 +115,7 @@ final class LSPService: ObservableObject { } /// Holds the active language clients - var languageClients: [ClientKey: LanguageServerType] = [:] + @Published 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 @@ -123,6 +124,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 { @@ -208,7 +212,7 @@ final class LSPService: ObservableObject { /// - Parameter document: The code document that was opened. func openDocument(_ document: CodeFileDocument) { guard let workspace = document.findWorkspace(), - let workspacePath = workspace.fileURL?.absoluteURL.path(), + let workspacePath = workspace.fileURL?.absolutePath, let lspLanguage = document.getLanguage().lspLanguage else { return } @@ -221,6 +225,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 @@ -300,14 +305,13 @@ final class LSPService: ObservableObject { /// Goes through all active language servers and attempts to shut them down. func stopAllServers() async { - await withThrowingTaskGroup(of: Void.self) { group in + await withTaskGroup(of: Void.self) { group in for (key, server) in languageClients { group.addTask { do { try await server.shutdown() } catch { - self.logger.error("Shutting down \(key.languageId.rawValue): Error \(error)") - throw error + self.logger.warning("Shutting down \(key.languageId.rawValue): Error \(error)") } } } @@ -318,4 +322,44 @@ final class LSPService: ObservableObject { } eventListeningTasks.removeAll() } + + /// Call this when a server is refusing to terminate itself. Sends the `SIGKILL` signal to all lsp processes. + func killAllServers() { + for (_, server) in languageClients { + kill(server.pid, SIGKILL) + } + } +} + +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 { + case serverNotFound + case serverStartFailed + case serverStopFailed + case languageClientNotFound } diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift index f014492aed..baefe9d8cd 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift +++ b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift @@ -18,7 +18,6 @@ struct FindNavigatorForm: View { } } - @State private var replaceText: String = "" @State private var includesText: String = "" @State private var excludesText: String = "" @State private var scoped: Bool = false @@ -26,63 +25,13 @@ struct FindNavigatorForm: View { @State private var preserveCase: Bool = false @State private var scopedToOpenEditors: Bool = false @State private var excludeSettings: Bool = true + @FocusState private var isSearchFieldFocused: Bool init(state: WorkspaceDocument.SearchState) { self.state = state selectedMode = state.selectedMode } - private func getMenuList(_ index: Int) -> [SearchModeModel] { - index == 0 ? SearchModeModel.SearchModes : selectedMode[index - 1].children - } - - private func onSelectMenuItem(_ index: Int, searchMode: SearchModeModel) { - var newSelectedMode: [SearchModeModel] = [] - - switch index { - case 0: - newSelectedMode.append(searchMode) - self.updateSelectedMode(searchMode, searchModel: &newSelectedMode) - self.selectedMode = newSelectedMode - case 1: - if let firstMode = selectedMode.first { - newSelectedMode.append(contentsOf: [firstMode, searchMode]) - if let thirdMode = searchMode.children.first { - if let selectedThirdMode = selectedMode.third, searchMode.children.contains(selectedThirdMode) { - newSelectedMode.append(selectedThirdMode) - } else { - newSelectedMode.append(thirdMode) - } - } - } - self.selectedMode = newSelectedMode - case 2: - if let firstMode = selectedMode.first, let secondMode = selectedMode.second { - newSelectedMode.append(contentsOf: [firstMode, secondMode, searchMode]) - } - self.selectedMode = newSelectedMode - default: - return - } - } - - private func updateSelectedMode(_ searchMode: SearchModeModel, searchModel: inout [SearchModeModel]) { - if let secondMode = searchMode.children.first { - if let selectedSecondMode = selectedMode.second, searchMode.children.contains(selectedSecondMode) { - searchModel.append(contentsOf: selectedMode.dropFirst()) - } else { - searchModel.append(secondMode) - if let thirdMode = secondMode.children.first, let selectedThirdMode = selectedMode.third { - if secondMode.children.contains(selectedThirdMode) { - searchModel.append(selectedThirdMode) - } else { - searchModel.append(thirdMode) - } - } - } - } - } - private var chevron: some View { Image(systemName: "chevron.compact.right") .foregroundStyle(.tertiary) @@ -153,6 +102,7 @@ struct FindNavigatorForm: View { }, hasValue: caseSensitive ) + .focused($isSearchFieldFocused) .onSubmit { if !state.searchQuery.isEmpty { Task { @@ -167,7 +117,7 @@ struct FindNavigatorForm: View { if selectedMode[0] == SearchModeModel.Replace { PaneTextField( "With", - text: $replaceText, + text: $state.replaceText, axis: .vertical, leadingAccessories: { Image(systemName: "arrow.2.squarepath") @@ -254,7 +204,7 @@ struct FindNavigatorForm: View { Button { Task { let startTime = Date() - try? await state.findAndReplace(query: state.searchQuery, replacingTerm: replaceText) + try? await state.findAndReplace(query: state.searchQuery, replacingTerm: state.replaceText) print(Date().timeIntervalSince(startTime)) } } label: { @@ -263,16 +213,65 @@ struct FindNavigatorForm: View { } } } + .onReceive(state.$shouldFocusSearchField) { shouldFocus in + if shouldFocus { + isSearchFieldFocused = true + state.shouldFocusSearchField = false + } + } .lineLimit(1...5) } } -extension Array { - var second: Element? { - self.count > 1 ? self[1] : nil +extension FindNavigatorForm { + private func getMenuList(_ index: Int) -> [SearchModeModel] { + index == 0 ? SearchModeModel.SearchModes : selectedMode[index - 1].children + } + + private func onSelectMenuItem(_ index: Int, searchMode: SearchModeModel) { + var newSelectedMode: [SearchModeModel] = [] + + switch index { + case 0: + newSelectedMode.append(searchMode) + self.updateSelectedMode(searchMode, searchModel: &newSelectedMode) + self.selectedMode = newSelectedMode + case 1: + if let firstMode = selectedMode.first { + newSelectedMode.append(contentsOf: [firstMode, searchMode]) + if let thirdMode = searchMode.children.first { + if let selectedThirdMode = selectedMode.third, searchMode.children.contains(selectedThirdMode) { + newSelectedMode.append(selectedThirdMode) + } else { + newSelectedMode.append(thirdMode) + } + } + } + self.selectedMode = newSelectedMode + case 2: + if let firstMode = selectedMode.first, let secondMode = selectedMode.second { + newSelectedMode.append(contentsOf: [firstMode, secondMode, searchMode]) + } + self.selectedMode = newSelectedMode + default: + return + } } - var third: Element? { - self.count > 2 ? self[2] : nil + private func updateSelectedMode(_ searchMode: SearchModeModel, searchModel: inout [SearchModeModel]) { + if let secondMode = searchMode.children.first { + if let selectedSecondMode = selectedMode.second, searchMode.children.contains(selectedSecondMode) { + searchModel.append(contentsOf: selectedMode.dropFirst()) + } else { + searchModel.append(secondMode) + if let thirdMode = secondMode.children.first, let selectedThirdMode = selectedMode.third { + if secondMode.children.contains(selectedThirdMode) { + searchModel.append(selectedThirdMode) + } else { + searchModel.append(thirdMode) + } + } + } + } } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index a7be86bc8e..a072d80c27 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -59,8 +59,8 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { }) .store(in: &cancellables) workspace.editorManager?.tabBarTabIdSubject - .sink { [weak self] itemID in - self?.controller?.updateSelection(itemID: itemID) + .sink { [weak self] editorInstance in + self?.controller?.updateSelection(itemID: editorInstance?.file.id) } .store(in: &cancellables) workspace.$navigatorFilter diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift index 920bdf2c48..a336df84a9 100644 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift +++ b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift @@ -12,7 +12,8 @@ struct OpenQuicklyPreviewView: View { private let queue = DispatchQueue(label: "app.codeedit.CodeEdit.quickOpen.preview") private let item: CEWorkspaceFile - @ObservedObject var document: CodeFileDocument + @StateObject var editorInstance: EditorInstance + @StateObject var document: CodeFileDocument init(item: CEWorkspaceFile) { self.item = item @@ -21,12 +22,13 @@ struct OpenQuicklyPreviewView: View { withContentsOf: item.url, ofType: item.contentType?.identifier ?? "public.source-code" ) + self._editorInstance = .init(wrappedValue: EditorInstance(workspace: nil, file: item)) self._document = .init(wrappedValue: doc ?? .init()) } var body: some View { if let utType = document.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: document, isEditable: false) + CodeFileView(editorInstance: editorInstance, codeFile: document, isEditable: false) } else { NonTextFileView(fileDocument: document) } diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index 7ba65b6998..cd860c7e43 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() + /// Language Server Settings + var languageServers: LanguageServerSettings = .init() + /// Developer settings for CodeEdit developers var developerSettings: DeveloperSettings = .init() @@ -74,6 +77,9 @@ struct SettingsData: Codable, Hashable { KeybindingsSettings.self, forKey: .keybindings ) ?? .init() + self.languageServers = try container.decodeIfPresent( + LanguageServerSettings.self, forKey: .languageServers + ) ?? .init() self.developerSettings = try container.decodeIfPresent( DeveloperSettings.self, forKey: .developerSettings ) ?? .init() @@ -102,6 +108,10 @@ 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 .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 ff45c21a03..597532b556 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 languageServers = "Language Servers" case developer = "Developer" } diff --git a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift index eac495daef..0b1bcf0ba4 100644 --- a/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/DeveloperSettings/DeveloperSettingsView.swift @@ -34,7 +34,10 @@ struct DeveloperSettingsView: View { Text( "Specify the absolute path to your LSP binary and its associated language." ) + } actionBarTrailing: { + EmptyView() } + .frame(minHeight: 96) } header: { Text("LSP Binaries") Text("Specify the language and the absolute path to the language server binary.") diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift new file mode 100644 index 0000000000..b1cdb7c6f3 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -0,0 +1,255 @@ +// +// LanguageServerRowView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +private let iconSize: CGFloat = 26 + +struct LanguageServerRowView: View, Equatable { + let packageName: String + let subtitle: String + let onCancel: (() -> Void) + let onInstall: (() async -> Void) + + private let cleanedTitle: String + private let cleanedSubtitle: String + + @State private var isHovering: Bool = false + @State private var installationStatus: PackageInstallationStatus = .notQueued + @State private var isInstalled: Bool = false + @State private var isEnabled = false + @State private var showingRemovalConfirmation = false + @State private var isRemoving = false + @State private var removalError: Error? + @State private var showingRemovalError = false + + init( + packageName: String, + subtitle: String, + isInstalled: Bool = false, + isEnabled: Bool = false, + onCancel: @escaping (() -> Void), + onInstall: @escaping () async -> Void + ) { + self.packageName = packageName + self.subtitle = subtitle + self.isInstalled = isInstalled + self.isEnabled = isEnabled + self.onCancel = onCancel + self.onInstall = onInstall + + self.cleanedTitle = packageName + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word -> String in + let str = String(word).lowercased() + // Check for special cases + if str == "ls" || str == "lsp" || str == "ci" || str == "cli" { + return str.uppercased() + } + return str.capitalized + } + .joined(separator: " ") + self.cleanedSubtitle = subtitle.replacingOccurrences(of: "\n", with: " ") + } + + var body: some View { + HStack { + Label { + VStack(alignment: .leading) { + Text(cleanedTitle) + Text(cleanedSubtitle) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } icon: { + letterIcon() + } + .opacity(isInstalled && !isEnabled ? 0.5 : 1.0) + + Spacer() + + installationButton() + } + .onHover { hovering in + isHovering = hovering + } + .onAppear { + // Check if this package is already in the installation queue + installationStatus = InstallationQueueManager.shared.getInstallationStatus(packageName: packageName) + } + .onReceive(NotificationCenter.default.publisher(for: .installationStatusChanged)) { notification in + if let notificationPackageName = notification.userInfo?["packageName"] as? String, + notificationPackageName == packageName, + let status = notification.userInfo?["status"] as? PackageInstallationStatus { + installationStatus = status + if case .installed = status { + isInstalled = true + isEnabled = true + } + } + } + .alert("Remove \(cleanedTitle)?", isPresented: $showingRemovalConfirmation) { + 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 + private func installationButton() -> some View { + if isInstalled { + installedRow() + } else { + switch installationStatus { + case .installing, .queued: + isInstallingRow() + case .failed: + failedRow() + default: + if isHovering { + isHoveringRow() + } + } + } + } + + @ViewBuilder + private func installedRow() -> some View { + HStack { + if isRemoving { + ProgressView() + .controlSize(.small) + } else if isHovering { + Button { + showingRemovalConfirmation = true + } label: { + Text("Remove") + } + } + 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 { + 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()) + } + } + } + + @ViewBuilder + private func failedRow() -> some View { + Button { + // 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() + } + } label: { + Text("Install") + } + } + + @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 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 + ] + let hashValue = abs(cleanedTitle.hash) % 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 new file mode 100644 index 0000000000..e06ca10b11 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -0,0 +1,99 @@ +// +// ExtensionsSettingsView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +struct LanguageServersView: View { + @State private var didError = false + @State private var installationFailure: InstallationFailure? + @State private var registryItems: [RegistryItem] = [] + @State private var isLoading = true + + var body: some View { + SettingsForm { + Section { + EmptyView() + } header: { + Label( + "Warning: Language server installation is not complete. Use this at your own risk. It " + + "**WILL** break.", + systemImage: "exclamationmark.triangle.fill" + ) + .padding() + .foregroundStyle(.black) + .background(RoundedRectangle(cornerRadius: 12).fill(.yellow)) + } + + if isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + } else { + Section { + List(registryItems, id: \.name) { item in + LanguageServerRowView( + packageName: item.name, + subtitle: item.description, + isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, + isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, + onCancel: { + InstallationQueueManager.shared.cancelInstallation(packageName: item.name) + }, + onInstall: { + let item = item // Capture for closure + InstallationQueueManager.shared.queueInstallation(package: item) { result in + switch result { + case .success: + break + case .failure(let error): + didError = true + installationFailure = InstallationFailure(error: error.localizedDescription) + } + } + } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + } + } + } + } + .onAppear { + loadRegistryItems() + } + .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in + loadRegistryItems() + } + .onDisappear { + InstallationQueueManager.shared.cleanUpInstallationStatus() + } + .alert( + "Installation Failed", + isPresented: $didError, + presenting: installationFailure + ) { _ in + Button("Dismiss") { } + } message: { details in + Text(details.error) + } + } + + private func loadRegistryItems() { + isLoading = true + registryItems = RegistryManager.shared.registryItems + if !registryItems.isEmpty { + isLoading = false + } + } +} + +private struct InstallationFailure: Identifiable { + let error: String + let id = UUID() +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift new file mode 100644 index 0000000000..9e65691a98 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift @@ -0,0 +1,49 @@ +// +// LanguageServerSettings.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +extension SettingsData { + struct LanguageServerSettings: Codable, Hashable, SearchableSettingsPage { + + /// The search keys + var searchKeys: [String] { + [ + "Language Servers", + "LSP Binaries", + "Linters", + "Formatters", + "Debug Protocol", + "DAP", + ] + .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() { + 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/Pages/TextEditingSettings/InvisiblesSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift new file mode 100644 index 0000000000..d7c885d13e --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/InvisiblesSettingsView.swift @@ -0,0 +1,117 @@ +// +// InvisiblesSettingsView.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +import SwiftUI + +struct InvisiblesSettingsView: View { + typealias Config = SettingsData.TextEditingSettings.InvisibleCharactersConfig + + @Binding var invisibleCharacters: Config + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + VStack { + Toggle(isOn: $invisibleCharacters.showSpaces) { Text("Show Spaces") } + if invisibleCharacters.showSpaces { + TextField( + text: $invisibleCharacters.spaceReplacement, + prompt: Text("Default: \(Config.default.spaceReplacement)") + ) { + Text("Character used to render spaces") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + + VStack { + Toggle(isOn: $invisibleCharacters.showTabs) { Text("Show Tabs") } + if invisibleCharacters.showTabs { + TextField( + text: $invisibleCharacters.tabReplacement, + prompt: Text("Default: \(Config.default.tabReplacement)") + ) { + Text("Character used to render tabs") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + + VStack { + Toggle(isOn: $invisibleCharacters.showLineEndings) { Text("Show Line Endings") } + if invisibleCharacters.showLineEndings { + TextField( + text: $invisibleCharacters.lineFeedReplacement, + prompt: Text("Default: \(Config.default.lineFeedReplacement)") + ) { + Text("Character used to render line feeds (\\n)") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.carriageReturnReplacement, + prompt: Text("Default: \(Config.default.carriageReturnReplacement)") + ) { + Text("Character used to render carriage returns (Microsoft-style line endings)") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.paragraphSeparatorReplacement, + prompt: Text("Default: \(Config.default.paragraphSeparatorReplacement)") + ) { + Text("Character used to render paragraph separators") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + + TextField( + text: $invisibleCharacters.lineSeparatorReplacement, + prompt: Text("Default: \(Config.default.lineSeparatorReplacement)") + ) { + Text("Character used to render line separators") + .foregroundStyle(.secondary) + .font(.caption) + } + .autocorrectionDisabled() + } + } + } header: { + Text("Invisible Characters") + Text("Toggle whitespace symbols CodeEdit will render with replacement characters.") + } + .textFieldStyle(.roundedBorder) + } + .formStyle(.grouped) + Divider() + HStack { + Spacer() + Button { + dismiss() + } label: { + Text("Done") + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index decc93caef..b5b5abb5a4 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -32,6 +32,8 @@ extension SettingsData { "Show Minimap", "Reformat at Column", "Show Reformatting Guide", + "Invisibles", + "Warning Characters" ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -89,13 +91,18 @@ extension SettingsData { /// Show the reformatting guide in the editor var showReformattingGuide: Bool = false + var invisibleCharacters: InvisibleCharactersConfig = .default + + /// Map of unicode character codes to a note about them + var warningCharacters: WarningCharacters = .default + /// Default initializer init() { self.populateCommands() } /// Explicit decoder init for setting default values when key is not present in `JSON` - init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { // swiftlint:disable:this function_body_length let container = try decoder.container(keyedBy: CodingKeys.self) self.defaultTabWidth = try container.decodeIfPresent(Int.self, forKey: .defaultTabWidth) ?? 4 self.indentOption = try container.decodeIfPresent( @@ -145,6 +152,14 @@ extension SettingsData { Bool.self, forKey: .showReformattingGuide ) ?? false + self.invisibleCharacters = try container.decodeIfPresent( + InvisibleCharactersConfig.self, + forKey: .invisibleCharacters + ) ?? .default + self.warningCharacters = try container.decodeIfPresent( + WarningCharacters.self, + forKey: .warningCharacters + ) ?? .default self.populateCommands() } @@ -239,6 +254,57 @@ extension SettingsData { } } } + + struct InvisibleCharactersConfig: Equatable, Hashable, Codable { + static var `default`: InvisibleCharactersConfig = { + InvisibleCharactersConfig( + enabled: false, + showSpaces: true, + showTabs: true, + showLineEndings: true + ) + }() + + var enabled: Bool + + var showSpaces: Bool + var showTabs: Bool + var showLineEndings: Bool + + var spaceReplacement: String = "·" + var tabReplacement: String = "→" + + // Controlled by `showLineEndings` + var carriageReturnReplacement: String = "↵" + var lineFeedReplacement: String = "¬" + var paragraphSeparatorReplacement: String = "¶" + var lineSeparatorReplacement: String = "⏎" + } + + struct WarningCharacters: Equatable, Hashable, Codable { + static let `default`: WarningCharacters = WarningCharacters(enabled: true, characters: [ + 0x0003: "End of text", + + 0x00A0: "Non-breaking space", + 0x202F: "Narrow non-breaking space", + 0x200B: "Zero-width space", + 0x200C: "Zero-width non-joiner", + 0x2029: "Paragraph separator", + + 0x2013: "Em-dash", + 0x00AD: "Soft hyphen", + + 0x2018: "Left single quote", + 0x2019: "Right single quote", + 0x201C: "Left double quote", + 0x201D: "Right double quote", + + 0x037E: "Greek Question Mark" + ]) + + var enabled: Bool + var characters: [UInt16: String] + } } struct EditorFont: Codable, Hashable { diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index 6257cdef54..73d9eca772 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -12,6 +12,9 @@ struct TextEditingSettingsView: View { @AppSettings(\.textEditing) var textEditing + @State private var isShowingInvisibleCharacterSettings = false + @State private var isShowingWarningCharactersSettings = false + var body: some View { SettingsForm { Section { @@ -41,6 +44,10 @@ struct TextEditingSettingsView: View { Section { bracketPairHighlight } + Section { + invisibles + warningCharacters + } } } } @@ -240,4 +247,50 @@ private extension TextEditingSettingsView { ) .help("The column at which text should be reformatted.") } + + @ViewBuilder private var invisibles: some View { + HStack { + Text("Show Invisible Characters") + Spacer() + Toggle(isOn: $textEditing.invisibleCharacters.enabled, label: { EmptyView() }) + Button { + isShowingInvisibleCharacterSettings = true + } label: { + Text("Configure...") + } + .disabled(textEditing.invisibleCharacters.enabled == false) + } + .contentShape(Rectangle()) + .onTapGesture { + if textEditing.invisibleCharacters.enabled { + isShowingInvisibleCharacterSettings = true + } + } + .sheet(isPresented: $isShowingInvisibleCharacterSettings) { + InvisiblesSettingsView(invisibleCharacters: $textEditing.invisibleCharacters) + } + } + + @ViewBuilder private var warningCharacters: some View { + HStack { + Text("Show Warning Characters") + Spacer() + Toggle(isOn: $textEditing.warningCharacters.enabled, label: { EmptyView() }) + Button { + isShowingWarningCharactersSettings = true + } label: { + Text("Configure...") + } + .disabled(textEditing.warningCharacters.enabled == false) + } + .contentShape(Rectangle()) + .onTapGesture { + if textEditing.warningCharacters.enabled { + isShowingWarningCharactersSettings = true + } + } + .sheet(isPresented: $isShowingWarningCharactersSettings) { + WarningCharactersView(warningCharacters: $textEditing.warningCharacters) + } + } } diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index f790e4b232..c80fcf9dcd 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( + .languageServers, + baseColor: Color(hex: "#6A69DC"), // Purple + icon: .system("cube.box.fill") + ) + ), .init( SettingsPage( .developer, @@ -134,7 +141,7 @@ struct SettingsView: View { SettingsPageView(page, searchText: searchText) } } else if !page.isSetting { - if page.name == .developer && !showDeveloperSettings { + if (page.name == .developer || page.name == .languageServers) && !showDeveloperSettings { EmptyView() } else { SettingsPageView(page, searchText: searchText) @@ -191,6 +198,8 @@ struct SettingsView: View { SourceControlSettingsView() case .location: LocationsSettingsView() + case .languageServers: + LanguageServersView() case .developer: DeveloperSettingsView() default: diff --git a/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift b/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift new file mode 100644 index 0000000000..cf7bd58f20 --- /dev/null +++ b/CodeEdit/Features/Settings/Views/InvisibleCharacterWarningList.swift @@ -0,0 +1,66 @@ +// +// InvisibleCharacterWarningList.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +import SwiftUI + +struct InvisibleCharacterWarningList: View { + @Binding var items: [UInt16: String] + + @State private var selection: String? + + var body: some View { + KeyValueTable( + items: Binding( + get: { + items.reduce(into: [String: String]()) { dict, keyVal in + let hex = String(keyVal.key, radix: 16).uppercased() + let padding = String(repeating: "0", count: 4 - hex.count) + dict["U+" + padding + hex] = keyVal.value + } + }, + set: { dict in + items = dict.reduce(into: [UInt16: String]()) { dict, keyVal in + guard let intFromHex = UInt(hexString: String(keyVal.key.trimmingPrefix("U+"))), + intFromHex < UInt16.max else { + return + } + let charCode = UInt16(intFromHex) + dict[charCode] = keyVal.value + } + } + ), + keyColumnName: "Unicode Character Code", + valueColumnName: "Notes", + newItemInstruction: "Add A Character As A Hexidecimal Unicode Value", + actionBarTrailing: { + Button { + // Add defaults without removing user's data. We do still override notes here. + items = items.merging( + SettingsData.TextEditingSettings.WarningCharacters.default.characters, + uniquingKeysWith: { _, defaults in + defaults + } + ) + } label: { + Text("Restore Defaults") + } + .buttonStyle(PlainButtonStyle()) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: true, vertical: false) + .padding(.trailing, 4) + } + ) + .frame(minHeight: 96, maxHeight: .infinity) + .overlay { + if items.isEmpty { + Text("No warning characters") + .foregroundStyle(Color(.secondaryLabelColor)) + } + } + } +} diff --git a/CodeEdit/Features/Settings/Views/WarningCharactersView.swift b/CodeEdit/Features/Settings/Views/WarningCharactersView.swift new file mode 100644 index 0000000000..bc2c21133b --- /dev/null +++ b/CodeEdit/Features/Settings/Views/WarningCharactersView.swift @@ -0,0 +1,47 @@ +// +// WarningCharactersView.swift +// CodeEdit +// +// Created by Khan Winter on 6/16/25. +// + +import SwiftUI + +struct WarningCharactersView: View { + typealias Config = SettingsData.TextEditingSettings.WarningCharacters + + @Binding var warningCharacters: Config + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + InvisibleCharacterWarningList(items: $warningCharacters.characters) + } header: { + Text("Warning Characters") + Text( + "CodeEdit can help identify invisible or ambiguous characters, such as zero-width spaces," + + " directional quotes, and more. These will appear with a red block highlighting them." + + " You can disable characters or add more here." + ) + } + } + .formStyle(.grouped) + Divider() + HStack { + Spacer() + Button { + dismiss() + } label: { + Text("Done") + .frame(minWidth: 56) + } + .buttonStyle(.borderedProminent) + } + .padding() + } + } +} diff --git a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift index fe9af2d7c6..d912cfa0df 100644 --- a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift +++ b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift @@ -170,6 +170,8 @@ extension SourceControlManager { await setChangedFiles(status.changedFiles + status.untrackedFiles) await refreshStatusInFileManager() + } catch GitClient.GitClientError.notGitRepository { + await setChangedFiles([]) } catch { logger.error("Error fetching git status: \(error)") await setChangedFiles([]) diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index a47e851761..6939fca4ec 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -61,7 +61,7 @@ struct StatusBarCursorPositionLabel: View { .font(statusBarViewModel.statusBarFont) .foregroundColor(foregroundColor) .lineLimit(1) - .onReceive(editorInstance.cursorPositions) { newValue in + .onReceive(editorInstance.$cursorPositions) { newValue in self.cursorPositions = newValue } } @@ -78,7 +78,7 @@ struct StatusBarCursorPositionLabel: View { /// - Parameter range: The range to query. /// - Returns: The number of lines in the range. func getLines(_ range: NSRange) -> Int { - return editorInstance.rangeTranslator?.linesInRange(range) ?? 0 + return editorInstance.rangeTranslator.linesInRange(range) } /// Create a label string for cursor positions. @@ -115,7 +115,7 @@ struct StatusBarCursorPositionLabel: View { } // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" + return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column)" } } } diff --git a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift index 3b6d94cdb8..40cf6eb1ae 100644 --- a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift +++ b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift @@ -7,11 +7,14 @@ import SwiftUI import Combine +import SwiftTerm /// Stores the state of a task once it's executed class CEActiveTask: ObservableObject, Identifiable, Hashable { /// The current progress of the task. - @Published private(set) var output: String = "" + @Published var output: CEActiveTaskTerminalView? + + var hasOutputBeenConfigured: Bool = false /// The status of the task. @Published private(set) var status: CETaskStatus = .notRunning @@ -19,138 +22,138 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { /// The name of the associated task. @ObservedObject var task: CETask - var process: Process? - var outputPipe: Pipe? + /// Prevents tasks overwriting each other. + /// Say a user cancels one task, then runs it immediately, the cancel message should show and then the + /// starting message should show. If we don't add this modifier the starting message will be deleted. + var activeTaskID: UUID = UUID() + + var taskId: String { + task.id.uuidString + "-" + activeTaskID.uuidString + } + + var workspaceURL: URL? private var cancellables = Set() init(task: CETask) { self.task = task - self.process = Process() - self.outputPipe = Pipe() self.task.objectWillChange.sink { _ in self.objectWillChange.send() }.store(in: &cancellables) } - func run(workspaceURL: URL? = nil) { - Task { - // Reconstruct the full command to ensure it executes in the correct directory. - // Because: CETask only contains information about the relative path. - let fullCommand: String - if let workspaceURL = workspaceURL { - fullCommand = "cd \(workspaceURL.relativePath.escapedDirectory()) && \(task.fullCommand)" - } else { - fullCommand = task.fullCommand - } - guard let process, let outputPipe else { return } - - await updateTaskStatus(to: .running) - createStatusTaskNotification() - - process.terminationHandler = { [weak self] capturedProcess in - if let self { - Task { - await self.handleProcessFinished(terminationStatus: capturedProcess.terminationStatus) - } - } - } - - outputPipe.fileHandleForReading.readabilityHandler = { fileHandle in - if let data = String(bytes: fileHandle.availableData, encoding: .utf8), - !data.isEmpty { - Task { - await self.updateOutput(data) - } - } - } - - do { - try Shell.executeCommandWithShell( - process: process, - command: fullCommand, - environmentVariables: self.task.environmentVariablesDictionary, - shell: Shell.zsh, // TODO: Let user decide which shell to use - outputPipe: outputPipe - ) - } catch { print(error) } - } + @MainActor + func run(workspaceURL: URL?, shell: Shell? = nil) { + self.workspaceURL = workspaceURL + self.activeTaskID = UUID() // generate a new ID for this run + + createStatusTaskNotification() + updateTaskStatus(to: .running) + + let view = output ?? CEActiveTaskTerminalView(activeTask: self) + view.startProcess(workspaceURL: workspaceURL, shell: shell) + + output = view } - func handleProcessFinished(terminationStatus: Int32) async { - handleTerminationStatus(terminationStatus) + @MainActor + func handleProcessFinished(terminationStatus: Int32) { + // Shells add 128 to non-zero exit codes. + var terminationStatus = terminationStatus + if terminationStatus > 128 { + terminationStatus -= 128 + } - if terminationStatus == 0 { - await updateOutput("\nFinished running \(task.name).\n\n") - await updateTaskStatus(to: .finished) + switch terminationStatus { + case 0: + output?.newline() + output?.sendOutputMessage("Finished running \(task.name).") + output?.newline() + + updateTaskStatus(to: .finished) updateTaskNotification( title: "Finished Running \(task.name)", message: "", isLoading: false ) - } else if terminationStatus == 15 { - await updateOutput("\n\(task.name) cancelled.\n\n") - await updateTaskStatus(to: .notRunning) + case 2, 15: // SIGINT or SIGTERM + output?.newline() + output?.sendOutputMessage("\(task.name) cancelled.") + output?.newline() + + updateTaskStatus(to: .notRunning) updateTaskNotification( title: "\(task.name) cancelled", message: "", isLoading: false ) - } else { - await updateOutput("\nFailed to run \(task.name).\n\n") - await updateTaskStatus(to: .failed) + case 17: // SIGSTOP + updateTaskStatus(to: .stopped) + default: + output?.newline() + output?.sendOutputMessage("Failed to run \(task.name)") + output?.newline() + + updateTaskStatus(to: .failed) updateTaskNotification( title: "Failed Running \(task.name)", message: "", isLoading: false ) } - outputPipe?.fileHandleForReading.readabilityHandler = nil deleteStatusTaskNotification() } - func renew() { - if let process { - if process.isRunning { - process.terminate() - process.waitUntilExit() - } - self.process = Process() - outputPipe = Pipe() + @MainActor + func suspend() { + if let shellPID = output?.runningPID(), status == .running { + kill(shellPID, SIGSTOP) + updateTaskStatus(to: .stopped) } } - func suspend() { - if let process, status == .running { - process.suspend() - Task { - await updateTaskStatus(to: .stopped) - } + @MainActor + func resume() { + if let shellPID = output?.runningPID(), status == .running { + kill(shellPID, SIGCONT) + updateTaskStatus(to: .running) } } - func resume() { - if let process, status == .stopped { - process.resume() - Task { - await updateTaskStatus(to: .running) - } + func terminate() { + if let shellPID = output?.runningPID() { + kill(shellPID, SIGTERM) + } + } + + func interrupt() { + if let shellPID = output?.runningPID() { + kill(shellPID, SIGINT) } } + func waitForExit() { + if let shellPID = output?.runningPID() { + waitid(P_PGID, UInt32(shellPID), nil, 0) + } + } + + @MainActor func clearOutput() { - output = "" + output?.terminal.resetToInitialState() + output?.feed(text: "") } private func createStatusTaskNotification() { let userInfo: [String: Any] = [ - "id": self.task.id.uuidString, + "id": taskId, "action": "createWithPriority", "title": "Running \(self.task.name)", "message": "Running your task: \(self.task.name).", - "isLoading": true + "isLoading": true, + "workspace": workspaceURL as Any ] NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) @@ -158,9 +161,10 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { private func deleteStatusTaskNotification() { let deleteInfo: [String: Any] = [ - "id": "\(task.id.uuidString)", + "id": taskId, "action": "deleteWithDelay", - "delay": 3.0 + "delay": 3.0, + "workspace": workspaceURL as Any ] NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) @@ -168,8 +172,9 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { private func updateTaskNotification(title: String? = nil, message: String? = nil, isLoading: Bool? = nil) { var userInfo: [String: Any] = [ - "id": task.id.uuidString, - "action": "update" + "id": taskId, + "action": "update", + "workspace": workspaceURL as Any ] if let title { userInfo["title"] = title @@ -184,23 +189,15 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) } - private func updateTaskStatus(to taskStatus: CETaskStatus) async { - await MainActor.run { - self.status = taskStatus - } - } - - /// Updates the progress and output values on the main thread` - private func updateOutput(_ output: String) async { - await MainActor.run { - self.output += output - } + @MainActor + func updateTaskStatus(to taskStatus: CETaskStatus) { + self.status = taskStatus } static func == (lhs: CEActiveTask, rhs: CEActiveTask) -> Bool { return lhs.output == rhs.output && lhs.status == rhs.status && - lhs.process == rhs.process && + lhs.output?.process.shellPid == rhs.output?.process.shellPid && lhs.task == rhs.task } @@ -209,24 +206,4 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { hasher.combine(status) hasher.combine(task) } - - // OPTIONAL - func handleTerminationStatus(_ status: Int32) { - switch status { - case 0: - print("Process completed successfully.") - case 1: - print("General error.") - case 2: - print("Misuse of shell builtins.") - case 126: - print("Command invoked cannot execute.") - case 127: - print("Command not found.") - case 128: - print("Invalid argument to exit.") - default: - print("Process ended with exit code \(status).") - } - } } diff --git a/CodeEdit/Features/Tasks/TaskManager.swift b/CodeEdit/Features/Tasks/TaskManager.swift index aeb96e2deb..f185320cab 100644 --- a/CodeEdit/Features/Tasks/TaskManager.swift +++ b/CodeEdit/Features/Tasks/TaskManager.swift @@ -9,6 +9,7 @@ import SwiftUI import Combine /// This class handles the execution of tasks +@MainActor class TaskManager: ObservableObject { @Published var activeTasks: [UUID: CEActiveTask] = [:] @Published var selectedTaskID: UUID? @@ -19,7 +20,7 @@ class TaskManager: ObservableObject { private var workspaceURL: URL? private var settingsListener: AnyCancellable? - init(workspaceSettings: CEWorkspaceSettingsData, workspaceURL: URL? = nil) { + init(workspaceSettings: CEWorkspaceSettingsData, workspaceURL: URL?) { self.workspaceURL = workspaceURL self.workspaceSettings = workspaceSettings @@ -60,8 +61,7 @@ class TaskManager: ObservableObject { } func executeActiveTask() { - let task = workspaceSettings.tasks.first { $0.id == selectedTaskID } - guard let task else { return } + guard let task = workspaceSettings.tasks.first(where: { $0.id == selectedTaskID }) else { return } Task { await runTask(task: task) } @@ -71,7 +71,7 @@ class TaskManager: ObservableObject { // A process can only be started once, that means we have to renew the Process and Pipe // but don't initialize a new object. if let activeTask = activeTasks[task.id] { - activeTask.renew() + activeTask.terminate() // Wait until the task is no longer running. // The termination handler is asynchronous, so we avoid a race condition using this. while activeTask.status == .running { @@ -87,12 +87,6 @@ class TaskManager: ObservableObject { } } - private func createRunningTask(taskID: UUID, runningTask: CEActiveTask) async { - await MainActor.run { - activeTasks[taskID] = runningTask - } - } - func terminateActiveTask() { guard let taskID = selectedTaskID else { return @@ -138,10 +132,9 @@ class TaskManager: ObservableObject { /// /// - Parameter taskID: The ID of the task to terminate. func terminateTask(taskID: UUID) { - guard let process = activeTasks[taskID]?.process, process.isRunning else { - return + if let activeTask = activeTasks[taskID] { + activeTask.terminate() } - process.terminate() } /// Interrupts the task associated with the given task ID. @@ -156,10 +149,9 @@ class TaskManager: ObservableObject { /// /// - Parameter taskID: The ID of the task to interrupt. func interruptTask(taskID: UUID) { - guard let process = activeTasks[taskID]?.process, process.isRunning else { - return + if let activeTask = activeTasks[taskID] { + activeTask.interrupt() } - process.interrupt() } func stopAllTasks() { diff --git a/CodeEdit/Features/TerminalEmulator/Model/Shell.swift b/CodeEdit/Features/TerminalEmulator/Model/Shell.swift index ba556d46b6..9dd6257e30 100644 --- a/CodeEdit/Features/TerminalEmulator/Model/Shell.swift +++ b/CodeEdit/Features/TerminalEmulator/Model/Shell.swift @@ -28,56 +28,6 @@ enum Shell: String, CaseIterable { } } - /// Executes a shell command using a specified shell, with optional environment variables. - /// - /// - Parameters: - /// - process: The `Process` instance to be configured and run. - /// - command: The shell command to execute. - /// - environmentVariables: A dictionary of environment variables to set for the process. Default is `nil`. - /// - shell: The shell to use for executing the command. Default is `.bash`. - /// - outputPipe: The `Pipe` instance to capture standard output and standard error. - /// - Throws: An error if the process fails to run. - /// - /// ### Example - /// ```swift - /// let process = Process() - /// let outputPipe = Pipe() - /// try executeCommandWithShell( - /// process: process, - /// command: "echo 'Hello, World!'", - /// environmentVariables: ["PATH": "/usr/bin"], - /// shell: .bash, - /// outputPipe: outputPipe - /// ) - /// let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - /// let outputString = String(data: outputData, encoding: .utf8) - /// print(outputString) // Output: "Hello, World!" - /// ``` - public static func executeCommandWithShell( - process: Process, - command: String, - environmentVariables: [String: String]? = nil, - shell: Shell = .bash, - outputPipe: Pipe - ) throws { - // Setup envs' - process.environment = environmentVariables - // Set the executable to bash - process.executableURL = URL(fileURLWithPath: shell.url) - - // Pass the command as an argument - // `--login` argument is needed when using a shell with a process in Swift to ensure - // that the shell loads the user's profile settings (like .bash_profile or .profile), - // which configure the environment variables and other shell settings. - process.arguments = ["--login", "-c", command] - - process.standardOutput = outputPipe - process.standardError = outputPipe - - // Run the process - try process.run() - } - var defaultPath: String { switch self { case .bash: @@ -87,6 +37,28 @@ enum Shell: String, CaseIterable { } } + /// Create the exec arguments for a new shell with the given behavior. + /// - Parameters: + /// - interactive: The shell is interactive, accepts user input. + /// - login: A login shell. + /// - Returns: The argument string. + func execArguments(interactive: Bool, login: Bool) -> String? { + var args = "" + + switch self { + case .bash, .zsh: + if interactive { + args.append("i") + } + + if login { + args.append("l") + } + } + + return args.isEmpty ? nil : "-" + args + } + /// Gets the default shell from the current user and returns the string of the shell path. /// /// If getting the user's shell does not work, defaults to `zsh`, diff --git a/CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift b/CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift index ee3e9a1002..789b8cf83d 100644 --- a/CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift +++ b/CodeEdit/Features/TerminalEmulator/Model/ShellIntegration.swift @@ -20,6 +20,7 @@ enum ShellIntegration { static let userZDotDir = "USER_ZDOTDIR" static let zDotDir = "ZDOTDIR" static let ceInjection = "CE_INJECTION" + static let disableHistory = "CE_DISABLE_HISTORY" } /// Errors for shell integration setup. @@ -51,7 +52,12 @@ enum ShellIntegration { /// - Returns: An array of args to pass to the shell executable. /// - Throws: Errors involving filesystem operations. This function requires copying various files, which can /// throw. Can also throw ``ShellIntegration/Error`` errors if required files are not found in the bundle. - static func setUpIntegration(for shell: Shell, environment: inout [String], useLogin: Bool) throws -> [String] { + static func setUpIntegration( + for shell: Shell, + environment: inout [String], + useLogin: Bool, + interactive: Bool + ) throws -> [String] { do { logger.debug("Setting up shell: \(shell.rawValue)") var args: [String] = [] @@ -63,13 +69,17 @@ enum ShellIntegration { case .bash: try bash(&args) case .zsh: - try zsh(&args, &environment, useLogin) + try zsh(&environment) } if useLogin { environment.append("\(Variables.shellLogin)=1") } + if let execArgs = shell.execArguments(interactive: interactive, login: useLogin) { + args.append(execArgs) + } + return args } catch { // catch so we can log this here @@ -84,9 +94,11 @@ enum ShellIntegration { /// /// Sets the bash `--init-file` option to point to CE's shell integration script. This script will source the /// user's "real" init file and then install our required functions. - /// Also sets the `-i` option to initialize an interactive session. + /// Also sets the `-i` option to initialize an interactive session if `interactive` is true. /// - /// - Parameter args: The args to use for shell exec, will be modified by this function. + /// - Parameters: + /// - args: The args to use for shell exec, will be modified by this function. + /// - interactive: Set to true to use an interactive shell. private static func bash(_ args: inout [String]) throws { // Inject our own bash script that will execute the user's init files, then install our pre/post exec functions. guard let scriptURL = Bundle.main.url( @@ -95,7 +107,7 @@ enum ShellIntegration { ) else { throw Error.bashShellFileNotFound } - args += ["--init-file", scriptURL.path(), "-i"] + args.append(contentsOf: ["--init-file", scriptURL.path()]) } /// Sets up the `zsh` shell integration. @@ -111,14 +123,10 @@ enum ShellIntegration { /// - environment: Environment variables in an array. Formatted as `EnvVar=Value`. Will be modified by this /// function. /// - useLogin: Whether to use a login shell. - private static func zsh(_ args: inout [String], _ environment: inout [String], _ useLogin: Bool) throws { - // Interactive, login shell. - if useLogin { - args.append("-il") - } else { - args.append("-i") - } - + /// - interactive: Whether to use an interactive shell. + private static func zsh( + _ environment: inout [String] + ) throws { // All injection script URLs guard let profileScriptURL = Bundle.main.url( forResource: "codeedit_shell_integration_profile", diff --git a/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift b/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift index 9f5b4b069e..f210085249 100644 --- a/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift +++ b/CodeEdit/Features/TerminalEmulator/Model/TerminalCache.swift @@ -14,7 +14,7 @@ final class TerminalCache { static let shared: TerminalCache = TerminalCache() /// The cache of terminal views. - private var terminals: [UUID: CELocalProcessTerminalView] + private var terminals: [UUID: CELocalShellTerminalView] private init() { terminals = [:] @@ -23,7 +23,7 @@ final class TerminalCache { /// Get a cached terminal view. /// - Parameter id: The ID of the terminal. /// - Returns: The existing terminal, if it exists. - func getTerminalView(_ id: UUID) -> CELocalProcessTerminalView? { + func getTerminalView(_ id: UUID) -> CELocalShellTerminalView? { terminals[id] } @@ -31,7 +31,7 @@ final class TerminalCache { /// - Parameters: /// - id: The ID of the terminal. /// - view: The view representing the terminal's contents. - func cacheTerminalView(for id: UUID, view: CELocalProcessTerminalView) { + func cacheTerminalView(for id: UUID, view: CELocalShellTerminalView) { terminals[id] = view } diff --git a/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift b/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift new file mode 100644 index 0000000000..faffba32b2 --- /dev/null +++ b/CodeEdit/Features/TerminalEmulator/Views/CEActiveTaskTerminalView.swift @@ -0,0 +1,101 @@ +// +// CEActiveTaskTerminalView.swift +// CodeEdit +// +// Created by Khan Winter on 7/14/25. +// + +import AppKit +import SwiftTerm + +class CEActiveTaskTerminalView: CELocalShellTerminalView { + var activeTask: CEActiveTask + + private var cachedCaretColor: NSColor? + var isUserCommandRunning: Bool { + activeTask.status == .running || activeTask.status == .stopped + } + private var enableOutput: Bool = false + + init(activeTask: CEActiveTask) { + self.activeTask = activeTask + super.init(frame: .zero) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func startProcess( + workspaceURL url: URL?, + shell: Shell? = nil, + environment: [String] = [], + interactive: Bool = true + ) { + let terminalSettings = Settings.shared.preferences.terminal + + var terminalEnvironment: [String] = Terminal.getEnvironmentVariables() + terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal") + + guard let (shell, shellPath) = getShell(shell, userSetting: terminalSettings.shell) else { + return + } + let shellArgs = ["-lic", activeTask.task.command] + + terminalEnvironment.append(contentsOf: environment) + terminalEnvironment.append("\(ShellIntegration.Variables.disableHistory)=1") + terminalEnvironment.append( + contentsOf: activeTask.task.environmentVariables.map({ $0.key + "=" + $0.value }) + ) + + sendOutputMessage("Starting task: " + self.activeTask.task.name) + sendOutputMessage(self.activeTask.task.command) + newline() + + process.startProcess( + executable: shellPath, + args: shellArgs, + environment: terminalEnvironment, + execName: shell.rawValue, + currentDirectory: URL(filePath: activeTask.task.workingDirectory, relativeTo: url).absolutePath + ) + } + + override func processTerminated(_ source: LocalProcess, exitCode: Int32?) { + activeTask.handleProcessFinished(terminationStatus: exitCode ?? 1) + } + + func sendOutputMessage(_ message: String) { + sendSpecialSequence() + feed(text: message) + newline() + } + + func sendSpecialSequence() { + let start: [UInt8] = [0x1B, 0x5B, 0x37, 0x6D] + let end: [UInt8] = [0x1B, 0x5B, 0x30, 0x6D] + feed(byteArray: start[0.. pid_t? { + if process.shellPid != 0 { + return process.shellPid + } + return nil + } + + func getBufferAsString() -> String { + terminal.getText( + start: .init(col: 0, row: 0), + end: .init(col: terminal.cols, row: terminal.rows + terminal.buffer.yDisp) + ) + } +} diff --git a/CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift b/CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift new file mode 100644 index 0000000000..e755839c37 --- /dev/null +++ b/CodeEdit/Features/TerminalEmulator/Views/CELocalShellTerminalView.swift @@ -0,0 +1,202 @@ +// +// CELocalShellTerminalView.swift +// CodeEdit +// +// Created by Khan Winter on 8/7/24. +// + +import AppKit +import SwiftTerm +import Foundation + +/// # Dev Note (please read) +/// +/// This entire file is a nearly 1:1 copy of SwiftTerm's `LocalProcessTerminalView`. The exception being the use of +/// `CETerminalView` over `TerminalView`. This change was made to fix the terminal clearing when the view was given a +/// frame of `0`. This enables terminals to keep running in the background, and allows them to be removed and added +/// back into the hierarchy for use in the utility area. +/// +/// # 07/15/25 +/// This has now been updated so that it differs from `LocalProcessTerminalView` in enough important ways that it +/// should not be removed in the future even if SwiftTerm has a change in behavior. + +protocol CELocalShellTerminalViewDelegate: AnyObject { + /// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows + /// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the + /// window itself + /// - Parameter source: the sending instance + /// - Parameter newCols: the new number of columns that should be shown + /// - Parameter newRow: the new number of rows that should be shown + func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) + + /// This method is invoked when the title of the terminal window should be updated to the provided title + /// - Parameter source: the sending instance + /// - Parameter title: the desired title + func setTerminalTitle(source: CETerminalView, title: String) + + /// Invoked when the OSC command 7 for "current directory has changed" command is sent + /// - Parameter source: the sending instance + /// - Parameter directory: the new working directory + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) + + /// This method will be invoked when the child process started by `startProcess` has terminated. + /// - Parameter source: the local process that terminated + /// - Parameter exitCode: the exit code returned by the process, or nil if this was an error caused during + /// the IO reading/writing + func processTerminated(source: TerminalView, exitCode: Int32?) +} + +// MARK: - CELocalShellTerminalView + +class CELocalShellTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate { + var process: LocalProcess! + + override public init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// The `processDelegate` is used to deliver messages and information relevant to the execution of the terminal. + public weak var processDelegate: CELocalShellTerminalViewDelegate? + + func setup() { + terminal = Terminal(delegate: self, options: TerminalOptions(scrollback: 2000)) + terminalDelegate = self + process = LocalProcess(delegate: self) + } + + /// Launches a child process inside a pseudo-terminal. + /// - Parameters: + /// - workspaceURL: The URL of the workspace to start at. + /// - shell: The shell to use, leave as `nil` to + public func startProcess( + workspaceURL url: URL?, + shell: Shell? = nil, + environment: [String] = [], + interactive: Bool = true + ) { + let terminalSettings = Settings.shared.preferences.terminal + + var terminalEnvironment: [String] = Terminal.getEnvironmentVariables() + terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal") + + guard let (shell, shellPath) = getShell(shell, userSetting: terminalSettings.shell) else { + return + } + + processDelegate?.setTerminalTitle(source: self, title: shell.rawValue) + + do { + let shellArgs: [String] + if terminalSettings.useShellIntegration { + shellArgs = try ShellIntegration.setUpIntegration( + for: shell, + environment: &terminalEnvironment, + useLogin: terminalSettings.useLoginShell, + interactive: interactive + ) + } else { + shellArgs = [] + } + + terminalEnvironment.append(contentsOf: environment) + + process.startProcess( + executable: shellPath, + args: shellArgs, + environment: terminalEnvironment, + execName: shell.rawValue, + currentDirectory: url?.absolutePath + ) + } catch { + terminal.feed(text: "Failed to start a terminal session: \(error.localizedDescription)") + } + } + + /// Returns a string of a shell path to use + func getShell(_ shellType: Shell?, userSetting: SettingsData.TerminalShell) -> (Shell, String)? { + if let shellType { + return (shellType, shellType.defaultPath) + } + switch userSetting { + case .system: + let defaultShell = Shell.autoDetectDefaultShell() + guard let type = Shell(rawValue: NSString(string: defaultShell).lastPathComponent) else { return nil } + return (type, defaultShell) + case .bash: + return (.bash, "/bin/bash") + case .zsh: + return (.zsh, "/bin/zsh") + } + } + + // MARK: - TerminalViewDelegate + + /// This method is invoked to notify the client of the new columsn and rows that have been set by the UI + public func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { + guard process.running else { + return + } + var size = getWindowSize() + _ = PseudoTerminalHelpers.setWinSize(masterPtyDescriptor: process.childfd, windowSize: &size) + + processDelegate?.sizeChanged(source: self, newCols: newCols, newRows: newRows) + } + + public func clipboardCopy(source: TerminalView, content: Data) { + if let str = String(bytes: content, encoding: .utf8) { + let pasteBoard = NSPasteboard.general + pasteBoard.clearContents() + pasteBoard.writeObjects([str as NSString]) + } + } + + public func rangeChanged(source: TerminalView, startY: Int, endY: Int) { } + + /// Invoke this method to notify the processDelegate of the new title for the terminal window + public func setTerminalTitle(source: TerminalView, title: String) { + processDelegate?.setTerminalTitle(source: self, title: title) + } + + public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { + processDelegate?.hostCurrentDirectoryUpdate(source: source, directory: directory) + } + + /// Passes data from the terminal to the shell. + /// Eg, the user types characters, this forwards the data to the shell. + public func send(source: TerminalView, data: ArraySlice) { + process.send(data: data) + } + + public func scrolled(source: TerminalView, position: Double) { } + + // MARK: - LocalProcessDelegate + + /// Implements the LocalProcessDelegate method. + public func processTerminated(_ source: LocalProcess, exitCode: Int32?) { + processDelegate?.processTerminated(source: self, exitCode: exitCode) + } + + /// Implements the LocalProcessDelegate.dataReceived method + /// + /// Passes data from the shell to the terminal. + public func dataReceived(slice: ArraySlice) { + feed(byteArray: slice) + } + + /// Implements the LocalProcessDelegate.getWindowSize method + public func getWindowSize() -> winsize { + let frame: CGRect = self.frame + return winsize( + ws_row: UInt16(getTerminal().rows), + ws_col: UInt16(getTerminal().cols), + ws_xpixel: UInt16(frame.width), + ws_ypixel: UInt16(frame.height) + ) + } +} diff --git a/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift b/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift index 3a0b8b05c9..3541da9a52 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift +++ b/CodeEdit/Features/TerminalEmulator/Views/CETerminalView.swift @@ -2,159 +2,68 @@ // CETerminalView.swift // CodeEdit // -// Created by Khan Winter on 8/7/24. +// Created by Khan Winter on 7/11/25. // -import AppKit import SwiftTerm -import Foundation +import AppKit -/// # Dev Note (please read) -/// -/// This entire file is a nearly 1:1 copy of SwiftTerm's `LocalProcessTerminalView`. The exception being the use of -/// `CETerminalView` over `TerminalView`. This change was made to fix the terminal clearing when the view was given a -/// frame of `0`. This enables terminals to keep running in the background, and allows them to be removed and added -/// back into the hierarchy for use in the utility area. -/// -/// If there is a bug here: **there probably isn't**. Look instead in ``TerminalEmulatorView``. +/// # Please see dev note in ``CELocalShellTerminalView``! class CETerminalView: TerminalView { - override var frame: NSRect { - get { - return super.frame - } - set(newValue) { - if newValue != .zero { - super.frame = newValue - } + override func setFrameSize(_ newSize: NSSize) { + if newSize != .zero { + super.setFrameSize(newSize) } } -} - -protocol CELocalProcessTerminalViewDelegate: AnyObject { - /// This method is invoked to notify that the terminal has been resized to the specified number of columns and rows - /// the user interface code might try to adjust the containing scroll view, or if it is a top level window, the - /// window itself - /// - Parameter source: the sending instance - /// - Parameter newCols: the new number of columns that should be shown - /// - Parameter newRow: the new number of rows that should be shown - func sizeChanged(source: CETerminalView, newCols: Int, newRows: Int) - - /// This method is invoked when the title of the terminal window should be updated to the provided title - /// - Parameter source: the sending instance - /// - Parameter title: the desired title - func setTerminalTitle(source: CETerminalView, title: String) - - /// Invoked when the OSC command 7 for "current directory has changed" command is sent - /// - Parameter source: the sending instance - /// - Parameter directory: the new working directory - func hostCurrentDirectoryUpdate (source: TerminalView, directory: String?) - - /// This method will be invoked when the child process started by `startProcess` has terminated. - /// - Parameter source: the local process that terminated - /// - Parameter exitCode: the exit code returned by the process, or nil if this was an error caused during - /// the IO reading/writing - func processTerminated (source: TerminalView, exitCode: Int32?) -} - -class CELocalProcessTerminalView: CETerminalView, TerminalViewDelegate, LocalProcessDelegate { - var process: LocalProcess! - - override public init (frame: CGRect) { - super.init(frame: frame) - setup() - } - public required init? (coder: NSCoder) { - super.init(coder: coder) - setup() - } - - func setup () { - terminalDelegate = self - process = LocalProcess(delegate: self) - } - - /// The `processDelegate` is used to deliver messages and information relevant to the execution of the terminal. - public weak var processDelegate: CELocalProcessTerminalViewDelegate? - - /// This method is invoked to notify the client of the new columsn and rows that have been set by the UI - public func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { - guard process.running else { - return + override open var frame: CGRect { + get { + super.frame } - var size = getWindowSize() - _ = PseudoTerminalHelpers.setWinSize(masterPtyDescriptor: process.childfd, windowSize: &size) - - processDelegate?.sizeChanged(source: self, newCols: newCols, newRows: newRows) - } - - public func clipboardCopy(source: TerminalView, content: Data) { - if let str = String(bytes: content, encoding: .utf8) { - let pasteBoard = NSPasteboard.general - pasteBoard.clearContents() - pasteBoard.writeObjects([str as NSString]) + set { + if newValue.size != .zero { + super.frame = newValue + } } } - public func rangeChanged(source: TerminalView, startY: Int, endY: Int) { } - - /// Invoke this method to notify the processDelegate of the new title for the terminal window - public func setTerminalTitle(source: TerminalView, title: String) { - processDelegate?.setTerminalTitle(source: self, title: title) + @objc + override open func copy(_ sender: Any) { + let range = selectedPositions() + let text = terminal.getText(start: range.start, end: range.end) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) } - public func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { - processDelegate?.hostCurrentDirectoryUpdate(source: source, directory: directory) + override open func isAccessibilityElement() -> Bool { + true } - /// This method is invoked when input from the user needs to be sent to the client - public func send(source: TerminalView, data: ArraySlice) { - process.send(data: data) + override open func isAccessibilityEnabled() -> Bool { + true } - /// Use this method to toggle the logging of data coming from the host, or pass nil to stop - public func setHostLogging (directory: String?) { - process.setHostLogging(directory: directory) + override open func accessibilityLabel() -> String? { + "Terminal Emulator" } - public func scrolled(source: TerminalView, position: Double) { } - - /// Launches a child process inside a pseudo-terminal. - /// - Parameter executable: The executable to launch inside the pseudo terminal, defaults to /bin/bash - /// - Parameter args: an array of strings that is passed as the arguments to the underlying process - /// - Parameter environment: an array of environment variables to pass to the child process, if this is null, - /// this picks a good set of defaults from `Terminal.getEnvironmentVariables`. - /// - Parameter execName: If provided, this is used as the Unix argv[0] parameter, - /// otherwise, the executable is used as the args [0], this is used when - /// the intent is to set a different process name than the file that backs it. - public func startProcess( - executable: String = "/bin/bash", - args: [String] = [], - environment: [String]? = nil, - execName: String? = nil - ) { - process.startProcess(executable: executable, args: args, environment: environment, execName: execName) + override open func accessibilityRole() -> NSAccessibility.Role? { + .textArea } - /// Implements the LocalProcessDelegate method. - public func processTerminated(_ source: LocalProcess, exitCode: Int32?) { - processDelegate?.processTerminated(source: self, exitCode: exitCode) + override open func accessibilityValue() -> Any? { + terminal.getText( + start: Position(col: 0, row: 0), + end: Position(col: terminal.buffer.x, row: terminal.getTopVisibleRow() + terminal.rows) + ) } - /// Implements the LocalProcessDelegate.dataReceived method - public func dataReceived(slice: ArraySlice) { - feed(byteArray: slice) + override open func accessibilitySelectedText() -> String? { + let range = selectedPositions() + let text = terminal.getText(start: range.start, end: range.end) + return text } - /// Implements the LocalProcessDelegate.getWindowSize method - public func getWindowSize() -> winsize { - let frame: CGRect = self.frame - return winsize( - ws_row: UInt16(getTerminal().rows), - ws_col: UInt16(getTerminal().cols), - ws_xpixel: UInt16(frame.width), - ws_ypixel: UInt16(frame.height) - ) - } } diff --git a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift b/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift index ae73b830d5..ad290c4f44 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift +++ b/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView+Coordinator.swift @@ -9,13 +9,16 @@ import SwiftUI import SwiftTerm extension TerminalEmulatorView { - final class Coordinator: NSObject, CELocalProcessTerminalViewDelegate { + final class Coordinator: NSObject, CELocalShellTerminalViewDelegate { private let terminalID: UUID public var onTitleChange: (_ title: String) -> Void - init(terminalID: UUID, onTitleChange: @escaping (_ title: String) -> Void) { + var mode: TerminalMode + + init(terminalID: UUID, mode: TerminalMode, onTitleChange: @escaping (_ title: String) -> Void) { self.terminalID = terminalID self.onTitleChange = onTitleChange + self.mode = mode super.init() } @@ -31,9 +34,11 @@ extension TerminalEmulatorView { guard let exitCode else { return } - source.feed(text: "Exit code: \(exitCode)\n\r\n") - source.feed(text: "To open a new session, create a new terminal tab.") - TerminalCache.shared.removeCachedView(terminalID) + if case .shell = mode { + source.feed(text: "Exit code: \(exitCode)\n\r\n") + source.feed(text: "To open a new session, create a new terminal tab.") + TerminalCache.shared.removeCachedView(terminalID) + } } } } diff --git a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift b/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift index 871445be5c..11683c9ce1 100644 --- a/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift +++ b/CodeEdit/Features/TerminalEmulator/Views/TerminalEmulatorView.swift @@ -18,6 +18,11 @@ import SwiftTerm /// Caches the view in the ``TerminalCache`` to keep terminal state when the view is removed from the hierarchy. /// struct TerminalEmulatorView: NSViewRepresentable { + enum TerminalMode { + case shell(shellType: Shell?) + case task(activeTask: CEActiveTask) + } + @AppSettings(\.terminal) var terminalSettings @AppSettings(\.textEditing.font) @@ -36,7 +41,7 @@ struct TerminalEmulatorView: NSViewRepresentable { private let terminalID: UUID private var url: URL - public var shellType: Shell? + public var mode: TerminalMode public var onTitleChange: (_ title: String) -> Void /// Create an emulator view @@ -48,37 +53,29 @@ struct TerminalEmulatorView: NSViewRepresentable { init(url: URL, terminalID: UUID, shellType: Shell? = nil, onTitleChange: @escaping (_ title: String) -> Void) { self.url = url self.terminalID = terminalID - self.shellType = shellType + self.mode = .shell(shellType: shellType) self.onTitleChange = onTitleChange } - // MARK: - Settings - - /// Returns a string of a shell path to use - private func getShell() -> String { - if let shellType { - return shellType.defaultPath - } - switch terminalSettings.shell { - case .system: - return Shell.autoDetectDefaultShell() - case .bash: - return "/bin/bash" - case .zsh: - return "/bin/zsh" - } + init(url: URL, task: CEActiveTask) { + terminalID = task.task.id + self.url = url + self.mode = .task(activeTask: task) + self.onTitleChange = { _ in } } + // MARK: - Settings + private func getTerminalCursor() -> CursorStyle { - let blink = terminalSettings.cursorBlink - switch terminalSettings.cursorStyle { - case .block: - return blink ? .blinkBlock : .steadyBlock - case .underline: - return blink ? .blinkUnderline : .steadyUnderline - case .bar: - return blink ? .blinkBar : .steadyBar - } + let blink = terminalSettings.cursorBlink + switch terminalSettings.cursorStyle { + case .block: + return blink ? .blinkBlock : .steadyBlock + case .underline: + return blink ? .blinkUnderline : .steadyUnderline + case .bar: + return blink ? .blinkBar : .steadyBar + } } /// Returns true if the `option` key should be treated as the `meta` key. @@ -149,60 +146,42 @@ struct TerminalEmulatorView: NSViewRepresentable { // MARK: - NSViewRepresentable /// Inherited from NSViewRepresentable.makeNSView(context:). - func makeNSView(context: Context) -> CELocalProcessTerminalView { - let terminalExists = TerminalCache.shared.getTerminalView(terminalID) != nil - let view = TerminalCache.shared.getTerminalView(terminalID) ?? CELocalProcessTerminalView(frame: .zero) - view.processDelegate = context.coordinator - if !terminalExists { // New terminal, start the shell process. - do { - try setupSession(view) - } catch { - view.feed(text: "Failed to start a terminal session: \(error.localizedDescription)") + func makeNSView(context: Context) -> CELocalShellTerminalView { + let view: CELocalShellTerminalView + + switch mode { + case .shell(let shellType): + let isCached = TerminalCache.shared.getTerminalView(terminalID) != nil + view = TerminalCache.shared.getTerminalView(terminalID) ?? CELocalShellTerminalView(frame: .zero) + if !isCached { + view.startProcess(workspaceURL: url, shell: shellType) + configureView(view) + } + case .task(let activeTask): + if let output = activeTask.output { + view = output + } else { + let newView = CEActiveTaskTerminalView(activeTask: activeTask) + activeTask.output = newView + view = newView + } + if !activeTask.hasOutputBeenConfigured { + configureView(view) + activeTask.hasOutputBeenConfigured = true } - configureView(view) } + + view.processDelegate = context.coordinator + TerminalCache.shared.cacheTerminalView(for: terminalID, view: view) return view } - /// Setup a new shell process. - /// - Parameter terminal: The terminal view to set up. - func setupSession(_ terminal: CELocalProcessTerminalView) throws { - // changes working directory to project root - // TODO: Get rid of FileManager shared instance to prevent problems - // using shared instance of FileManager might lead to problems when using - // multiple workspaces. This works for now but most probably will need - // to be changed later on - FileManager.default.changeCurrentDirectoryPath(url.path) - - var terminalEnvironment: [String] = Terminal.getEnvironmentVariables() - terminalEnvironment.append("TERM_PROGRAM=CodeEditApp_Terminal") - - let shellPath = getShell() - guard let shell = Shell(rawValue: NSString(string: shellPath).lastPathComponent) else { - return - } - onTitleChange(shell.rawValue) - - let shellArgs: [String] - if terminalSettings.useShellIntegration { - shellArgs = try ShellIntegration.setUpIntegration( - for: shell, - environment: &terminalEnvironment, - useLogin: terminalSettings.useLoginShell - ) - } else { - shellArgs = [] - } - - terminal.startProcess( - executable: shellPath, - args: shellArgs, - environment: terminalEnvironment, - execName: shell.rawValue - ) + func configureView(_ terminal: CELocalShellTerminalView) { + terminal.getTerminal().silentLog = true + terminal.appearance = colorAppearance + scroller(terminal)?.isHidden = true terminal.font = font - terminal.configureNativeColors() terminal.installColors(self.colors) terminal.caretColor = cursorColor.withAlphaComponent(0.5) terminal.caretTextColor = cursorColor.withAlphaComponent(0.5) @@ -210,17 +189,11 @@ struct TerminalEmulatorView: NSViewRepresentable { terminal.nativeForegroundColor = textColor terminal.nativeBackgroundColor = terminalSettings.useThemeBackground ? backgroundColor : .clear terminal.cursorStyleChanged(source: terminal.getTerminal(), newStyle: getTerminalCursor()) - terminal.layer?.backgroundColor = .clear + terminal.layer?.backgroundColor = CGColor.clear terminal.optionAsMetaKey = optionAsMeta } - func configureView(_ terminal: CELocalProcessTerminalView) { - terminal.getTerminal().silentLog = true - terminal.appearance = colorAppearance - scroller(terminal)?.isHidden = true - } - - private func scroller(_ terminal: CELocalProcessTerminalView) -> NSScroller? { + private func scroller(_ terminal: CELocalShellTerminalView) -> NSScroller? { for subView in terminal.subviews { if let scroller = subView as? NSScroller { return scroller @@ -229,8 +202,7 @@ struct TerminalEmulatorView: NSViewRepresentable { return nil } - func updateNSView(_ view: CELocalProcessTerminalView, context: Context) { - view.configureNativeColors() + func updateNSView(_ view: CELocalShellTerminalView, context: Context) { view.installColors(self.colors) view.caretColor = cursorColor.withAlphaComponent(0.5) view.caretTextColor = cursorColor.withAlphaComponent(0.5) @@ -246,6 +218,6 @@ struct TerminalEmulatorView: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(terminalID: terminalID, onTitleChange: onTitleChange) + Coordinator(terminalID: terminalID, mode: mode, onTitleChange: onTitleChange) } } diff --git a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift b/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift index bd375e4fc9..c5325280ee 100644 --- a/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift +++ b/CodeEdit/Features/UtilityArea/DebugUtility/TaskOutputView.swift @@ -9,13 +9,12 @@ import SwiftUI struct TaskOutputView: View { @ObservedObject var activeTask: CEActiveTask + var body: some View { - VStack(alignment: .leading) { - Text(activeTask.output) - .fontDesign(.monospaced) - .textSelection(.enabled) + if activeTask.output != nil, let workspaceURL = activeTask.workspaceURL { + TerminalEmulatorView(url: workspaceURL, task: activeTask) + } else { + EmptyView() } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding(10) } } diff --git a/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift b/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift index 0c98f6e5fe..996a66cbd3 100644 --- a/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift +++ b/CodeEdit/Features/UtilityArea/DebugUtility/UtilityAreaDebugView.swift @@ -15,6 +15,13 @@ struct UtilityAreaDebugView: View { @AppSettings(\.theme.useThemeBackground) private var useThemeBackground + @AppSettings(\.textEditing.font) + private var textEditingFont + @AppSettings(\.terminal.font) + private var terminalFont + @AppSettings(\.terminal.useTextEditorFont) + private var useTextEditorFont + @Environment(\.colorScheme) private var colorScheme @@ -27,45 +34,31 @@ struct UtilityAreaDebugView: View { @Namespace var bottomID + var font: NSFont { + useTextEditorFont == true ? textEditingFont.current : terminalFont.current + } + var body: some View { UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in ZStack { - HStack { - Spacer() - Text("No Task Selected") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - Spacer() - } - .opacity(taskManager.taskShowingOutput == nil ? 1 : 0) + HStack { Spacer() } if let taskShowingOutput = taskManager.taskShowingOutput, let activeTask = taskManager.activeTasks[taskShowingOutput] { - ScrollViewReader { proxy in - VStack { - ScrollView { - VStack { - TaskOutputView(activeTask: activeTask) - - Rectangle() - .frame(width: 1, height: 1) - .foregroundStyle(.clear) - .id(bottomID) + GeometryReader { geometry in + let containerHeight = geometry.size.height + let totalFontHeight = fontTotalHeight(nsFont: font).rounded(.up) + let constrainedHeight = containerHeight - containerHeight.truncatingRemainder( + dividingBy: totalFontHeight + ) + VStack(spacing: 0) { + Spacer(minLength: 0).frame(minHeight: 0) - }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - }.animation(.default, value: bottomID) - .onAppear { - // assign proxy to scrollProxy in order - // to use the button to scroll down and scroll down on reappear - scrollProxy = proxy - scrollProxy?.scrollTo(bottomID, anchor: .bottom) - } - Spacer() + TaskOutputView(activeTask: activeTask) + .frame(height: max(0, constrainedHeight - 1)) + .id(activeTask.task.id) + .padding(.horizontal, 10) } - .onReceive(activeTask.$output, perform: { _ in - proxy.scrollTo(bottomID) - }) } .paneToolbar { TaskOutputActionsView( @@ -90,11 +83,13 @@ struct UtilityAreaDebugView: View { } .colorScheme( utilityAreaViewModel.selectedTerminals.isEmpty - ? colorScheme - : matchAppearance && darkAppearance - ? themeModel.selectedDarkTheme?.appearance == .dark ? .dark : .light - : themeModel.selectedTheme?.appearance == .dark ? .dark : .light + ? colorScheme + : matchAppearance && darkAppearance + ? themeModel.selectedDarkTheme?.appearance == .dark ? .dark : .light + : themeModel.selectedTheme?.appearance == .dark ? .dark : .light ) + } else { + CEContentUnavailableView("No Task Selected") } } } leadingSidebar: { _ in @@ -145,4 +140,15 @@ struct UtilityAreaDebugView: View { } return .windowBackgroundColor } + + /// Estimate the font's height for keeping the terminal aligned with the bottom. + /// - Parameter nsFont: The font being used in the terminal. + /// - Returns: The height in pixels of the font. + private func fontTotalHeight(nsFont: NSFont) -> CGFloat { + let ctFont = nsFont as CTFont + let ascent = CTFontGetAscent(ctFont) + let descent = CTFontGetDescent(ctFont) + let leading = CTFontGetLeading(ctFont) + return ascent + descent + leading + } } diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift new file mode 100644 index 0000000000..ebfe6457db --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/ExtensionUtilityAreaOutputSource.swift @@ -0,0 +1,44 @@ +// +// ExtensionUtilityAreaOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LogStream + +extension LogMessage: @retroactive Identifiable, UtilityAreaOutputMessage { + public var id: String { + "\(date.timeIntervalSince1970)" + process + (subsystem ?? "") + (category ?? "") + } + + var level: UtilityAreaLogLevel { + switch type { + case .fault, .error: + .error + case .info, .default: + .info + case .debug: + .debug + default: + .info + } + } +} + +struct ExtensionUtilityAreaOutputSource: UtilityAreaOutputSource { + var id: String { + "extension_output" + extensionInfo.id + } + + let extensionInfo: ExtensionInfo + + func cachedMessages() -> [LogMessage] { + [] + } + + func streamMessages() -> AsyncStream { + LogStream.logs(for: extensionInfo.pid, flags: [.info, .historical, .processOnly]) + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift new file mode 100644 index 0000000000..0f489b8920 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/InternalDevelopmentOutputSource.swift @@ -0,0 +1,44 @@ +// +// InternalDevelopmentOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import Foundation + +class InternalDevelopmentOutputSource: UtilityAreaOutputSource { + static let shared = InternalDevelopmentOutputSource() + + struct Message: UtilityAreaOutputMessage { + var id: UUID = UUID() + + var message: String + var date: Date = Date() + var subsystem: String? + var category: String? + var level: UtilityAreaLogLevel + } + + var id: UUID = UUID() + private var logs: [Message] = [] + private(set) var streamContinuation: AsyncStream.Continuation + private var stream: AsyncStream + + init() { + (stream, streamContinuation) = AsyncStream.makeStream() + } + + func pushLog(_ log: Message) { + logs.append(log) + streamContinuation.yield(log) + } + + func cachedMessages() -> [Message] { + logs + } + + func streamMessages() -> AsyncStream { + stream + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift new file mode 100644 index 0000000000..60e29c0d89 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/Sources/LanguageServerLogContainer.swift @@ -0,0 +1,64 @@ +// +// LanguageServerLogContainer.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LanguageServerProtocol + +class LanguageServerLogContainer: UtilityAreaOutputSource { + struct LanguageServerMessage: UtilityAreaOutputMessage { + let log: LogMessageParams + var id: UUID = UUID() + + var message: String { + log.message + } + + var level: UtilityAreaLogLevel { + switch log.type { + case .error: + .error + case .warning: + .warning + case .info: + .info + case .log: + .debug + } + } + + var date: Date = Date() + var subsystem: String? + var category: String? + } + + let id: String + + private var streamContinuation: AsyncStream.Continuation + private var stream: AsyncStream + private(set) var logs: [LanguageServerMessage] = [] + + init(language: LanguageIdentifier) { + id = language.rawValue + (stream, streamContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(0) + ) + } + + func appendLog(_ log: LogMessageParams) { + let message = LanguageServerMessage(log: log) + logs.append(message) + streamContinuation.yield(message) + } + + func cachedMessages() -> [LanguageServerMessage] { + logs + } + + func streamMessages() -> AsyncStream { + stream + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift new file mode 100644 index 0000000000..9a04194384 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaLogLevel.swift @@ -0,0 +1,52 @@ +// +// UtilityAreaLogLevel.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +enum UtilityAreaLogLevel { + case error + case warning + case info + case debug + + var iconName: String { + switch self { + case .error: + "exclamationmark.3" + case .warning: + "exclamationmark.2" + case .info: + "info" + case .debug: + "stethoscope" + } + } + + var color: Color { + switch self { + case .error: + return Color(red: 202.0/255.0, green: 27.0/255.0, blue: 0) + case .warning: + return Color(red: 255.0/255.0, green: 186.0/255.0, blue: 0) + case .info: + return .cyan + case .debug: + return .coolGray + } + } + + var backgroundColor: Color { + switch self { + case .error: + color.opacity(0.1) + case .warning: + color.opacity(0.2) + case .info, .debug: + .clear + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift new file mode 100644 index 0000000000..2ef76aa8a9 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/Model/UtilityAreaOutputSource.swift @@ -0,0 +1,23 @@ +// +// UtilityAreaOutputSource.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import OSLog +import LogStream + +protocol UtilityAreaOutputMessage: Identifiable { + var message: String { get } + var date: Date { get } + var subsystem: String? { get } + var category: String? { get } + var level: UtilityAreaLogLevel { get } +} + +protocol UtilityAreaOutputSource: Identifiable { + associatedtype Message: UtilityAreaOutputMessage + func cachedMessages() -> [Message] + func streamMessages() -> AsyncStream +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift b/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift deleted file mode 100644 index 5c76724374..0000000000 --- a/CodeEdit/Features/UtilityArea/OutputUtility/UtilityAreaOutputView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// UtilityAreaOutputView.swift -// CodeEdit -// -// Created by Austin Condiff on 5/25/23. -// - -import SwiftUI -import LogStream - -struct UtilityAreaOutputView: View { - @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel - - @ObservedObject var extensionManager = ExtensionManager.shared - - @State var output: [LogMessage] = [] - - @State private var filterText = "" - - @State var selectedOutputSource: ExtensionInfo? - - var filteredOutput: [LogMessage] { - output.filter { item in - return filterText == "" ? true : item.message.contains(filterText) - } - } - - var body: some View { - UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in - Group { - if selectedOutputSource == nil { - Text("No output") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(maxHeight: .infinity) - } else { - if let ext = selectedOutputSource { - ScrollView { - VStack(alignment: .leading) { - ForEach(filteredOutput, id: \.self) { item in - HStack { - Text(item.message) - .fontWeight(.semibold) - .fontDesign(.monospaced) - .foregroundColor(item.type.color) - Spacer() - } - .padding(.leading, 2) - } - } - .padding(5) - .frame(maxWidth: .infinity) - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } - .rotationEffect(.radians(.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - .task(id: ext.pid) { - output = [] - for await item in LogStream.logs(for: ext.pid, flags: [.info, .historical, .processOnly]) { - output.append(item) - } - } - } - } - } - .paneToolbar { - Picker("Output Source", selection: $selectedOutputSource) { - Text("All Sources") - .tag(nil as ExtensionInfo?) - ForEach(extensionManager.extensions) { - Text($0.name) - .tag($0 as ExtensionInfo?) - } - } - .buttonStyle(.borderless) - .labelsHidden() - .controlSize(.small) - Spacer() - UtilityAreaFilterTextField(title: "Filter", text: $filterText) - .frame(maxWidth: 175) - Button { - output = [] - } label: { - Image(systemName: "trash") - } - } - } - } -} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift new file mode 100644 index 0000000000..ae606b3b11 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputLogList.swift @@ -0,0 +1,101 @@ +// +// UtilityAreaOutputLogList.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct UtilityAreaOutputLogList: View { + let source: Source + + @State var output: [Source.Message] = [] + @Binding var filterText: String + var toolbar: () -> Toolbar + + init(source: Source, filterText: Binding, @ViewBuilder toolbar: @escaping () -> Toolbar) { + self.source = source + self._filterText = filterText + self.toolbar = toolbar + } + + var filteredOutput: [Source.Message] { + if filterText.isEmpty { + return output + } + return output.filter { item in + return filterText == "" ? true : item.message.contains(filterText) + } + } + + var body: some View { + List(filteredOutput.reversed()) { item in + VStack(spacing: 2) { + HStack(spacing: 0) { + Text(item.message) + .fontDesign(.monospaced) + .font(.system(size: 12, weight: .regular).monospaced()) + Spacer(minLength: 0) + } + HStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: item.level.iconName) + .foregroundColor(.white) + .font(.system(size: 7, weight: .semibold)) + .frame(width: 12, height: 12) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(item.level.color) + .aspectRatio(1.0, contentMode: .fit) + ) + Text(item.date.logFormatted()) + .fontWeight(.medium) + } + if let subsystem = item.subsystem { + HStack(spacing: 2) { + Image(systemName: "gearshape.2") + .font(.system(size: 8, weight: .regular)) + Text(subsystem) + } + } + if let category = item.category { + HStack(spacing: 2) { + Image(systemName: "square.grid.3x3") + .font(.system(size: 8, weight: .regular)) + Text(category) + } + } + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .font(.system(size: 9, weight: .semibold).monospaced()) + } + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + .listRowBackground(item.level.backgroundColor) + } + .listStyle(.plain) + .listRowInsets(EdgeInsets()) + .rotationEffect(.radians(.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + .task(id: source.id) { + output = source.cachedMessages() + for await item in source.streamMessages() { + output.append(item) + } + } + .paneToolbar { + toolbar() + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) + Button { + output.removeAll(keepingCapacity: true) + } label: { + Image(systemName: "trash") + } + } + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift new file mode 100644 index 0000000000..4d65d39d80 --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputSourcePicker.swift @@ -0,0 +1,90 @@ +// +// UtilityAreaOutputSourcePicker.swift +// CodeEdit +// +// Created by Khan Winter on 7/18/25. +// + +import SwiftUI + +struct UtilityAreaOutputSourcePicker: View { + typealias Sources = UtilityAreaOutputView.Sources + + @EnvironmentObject private var workspace: WorkspaceDocument + + @AppSettings(\.developerSettings.showInternalDevelopmentInspector) + var showInternalDevelopmentInspector + + @Binding var selectedSource: Sources? + + @ObservedObject var extensionManager = ExtensionManager.shared + + @Service var lspService: LSPService + @State private var updater: UUID = UUID() + @State private var languageServerClients: [LSPService.LanguageServerType] = [] + + var body: some View { + Picker("Output Source", selection: $selectedSource) { + if selectedSource == nil { + Text("No Selected Output Source") + .italic() + .tag(Sources?.none) + Divider() + } + + if languageServerClients.isEmpty { + Text("No Language Servers") + } else { + ForEach(languageServerClients, id: \.languageId) { server in + Text(Sources.languageServer(server.logContainer).title) + .tag(Sources.languageServer(server.logContainer)) + } + } + + Divider() + + if extensionManager.extensions.isEmpty { + Text("No Extensions") + } else { + ForEach(extensionManager.extensions) { extensionInfo in + Text(Sources.extensions(.init(extensionInfo: extensionInfo)).title) + .tag(Sources.extensions(.init(extensionInfo: extensionInfo))) + } + } + + if showInternalDevelopmentInspector { + Divider() + Text(Sources.devOutput.title) + .tag(Sources.devOutput) + } + } + .id(updater) + .buttonStyle(.borderless) + .labelsHidden() + .controlSize(.small) + .onAppear { + updateLanguageServers(lspService.languageClients) + } + .onReceive(lspService.$languageClients) { clients in + updateLanguageServers(clients) + } + .onReceive(extensionManager.$extensions) { _ in + updater = UUID() + } + } + + func updateLanguageServers(_ clients: [LSPService.ClientKey: LSPService.LanguageServerType]) { + languageServerClients = clients + .compactMap { (key, value) in + if key.workspacePath == workspace.fileURL?.absolutePath { + return value + } + return nil + } + .sorted(by: { $0.languageId.rawValue < $1.languageId.rawValue }) + if selectedSource == nil, let client = languageServerClients.first { + selectedSource = Sources.languageServer(client.logContainer) + } + updater = UUID() + } +} diff --git a/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift new file mode 100644 index 0000000000..94bd5a5a7c --- /dev/null +++ b/CodeEdit/Features/UtilityArea/OutputUtility/View/UtilityAreaOutputView.swift @@ -0,0 +1,100 @@ +// +// UtilityAreaOutputView.swift +// CodeEdit +// +// Created by Austin Condiff on 5/25/23. +// + +import SwiftUI +import LogStream + +struct UtilityAreaOutputView: View { + enum Sources: Hashable { + case extensions(ExtensionUtilityAreaOutputSource) + case languageServer(LanguageServerLogContainer) + case devOutput + + var title: String { + switch self { + case .extensions(let source): + "Extension - \(source.extensionInfo.name)" + case .languageServer(let source): + "Language Server - \(source.id)" + case .devOutput: + "Internal Development Output" + } + } + + public static func == (_ lhs: Sources, _ rhs: Sources) -> Bool { + switch (lhs, rhs) { + case let (.extensions(lhs), .extensions(rhs)): + return lhs.id == rhs.id + case let (.languageServer(lhs), .languageServer(rhs)): + return lhs.id == rhs.id + case (.devOutput, .devOutput): + return true + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .extensions(let source): + hasher.combine(0) + hasher.combine(source.id) + case .languageServer(let source): + hasher.combine(1) + hasher.combine(source.id) + case .devOutput: + hasher.combine(2) + } + } + } + + @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel + + @State private var filterText: String = "" + @State private var selectedSource: Sources? + + var body: some View { + UtilityAreaTabView(model: utilityAreaViewModel.tabViewModel) { _ in + Group { + if let selectedSource { + switch selectedSource { + case .extensions(let source): + UtilityAreaOutputLogList(source: source, filterText: $filterText) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } + case .languageServer(let source): + UtilityAreaOutputLogList(source: source, filterText: $filterText) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } + case .devOutput: + UtilityAreaOutputLogList( + source: InternalDevelopmentOutputSource.shared, + filterText: $filterText + ) { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + } + } + } else { + Text("No output") + .font(.system(size: 16)) + .foregroundColor(.secondary) + .frame(maxHeight: .infinity) + .paneToolbar { + UtilityAreaOutputSourcePicker(selectedSource: $selectedSource) + Spacer() + UtilityAreaFilterTextField(title: "Filter", text: $filterText) + .frame(maxWidth: 175) + Button { } label: { + Image(systemName: "trash") + } + .disabled(true) + } + } + } + } + } +} diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift index 1c61d8bb41..4f72f5235b 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalSidebar.swift @@ -72,6 +72,7 @@ struct UtilityAreaTerminalSidebar: View { } .accessibilityElement(children: .contain) .accessibilityLabel("Terminals") + .accessibilityIdentifier("terminalsList") } } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift index 2ee68eb733..0daf98bec9 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalTab.swift @@ -46,7 +46,6 @@ struct UtilityAreaTerminalTab: View { } } icon: { Image(systemName: "terminal") - .accessibilityHidden(true) } .contextMenu { Button("Rename...") { @@ -63,5 +62,8 @@ struct UtilityAreaTerminalTab: View { } } } + .accessibilityElement(children: .contain) + .accessibilityLabel(terminalTitle.wrappedValue) + .accessibilityIdentifier("terminalTab") } } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift index 998ed620b7..9e6d047ca3 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift @@ -117,6 +117,7 @@ struct UtilityAreaTerminalView: View { ) .frame(height: max(0, constrainedHeight - 1)) .id(selectedTerminal.id) + .accessibilityIdentifier("terminal") } } } else { @@ -167,6 +168,7 @@ struct UtilityAreaTerminalView: View { } utilityAreaViewModel.initializeTerminals(workspaceURL: workspaceURL) } + .accessibilityIdentifier("terminal-area") } @ViewBuilder var backgroundEffectView: some View { diff --git a/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift b/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift deleted file mode 100644 index 94535886df..0000000000 --- a/CodeEdit/Features/UtilityArea/Views/OSLogType+Color.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// OSLogType+Color.swift -// CodeEdit -// -// Created by Wouter Hennen on 22/05/2023. -// - -import OSLog -import SwiftUI - -extension OSLogType { - var color: Color { - switch self { - case .error: - return .orange - case .debug, .default: - return .primary - case .fault: - return .red - case .info: - return .cyan - default: - return .green - } - } -} diff --git a/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift b/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift index 64f21ee1aa..6d35448169 100644 --- a/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift +++ b/CodeEdit/Features/UtilityArea/Views/UtilityAreaView.swift @@ -20,5 +20,6 @@ struct UtilityAreaView: View { ) .accessibilityElement(children: .contain) .accessibilityLabel("Utility Area") + .accessibilityIdentifier("UtilityArea") } } diff --git a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift index e2182de99b..5e2d664134 100644 --- a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift +++ b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift @@ -12,15 +12,18 @@ struct CodeEditCommands: Commands { private var sourceControlIsEnabled var body: some Commands { - MainCommands() - FileCommands() - ViewCommands() - FindCommands() - NavigateCommands() - if sourceControlIsEnabled { SourceControlCommands() } - EditorCommands() - ExtensionCommands() - WindowCommands() + Group { // SwiftUI limits to 9 items in an initializer, so we have to group every 9 items. + MainCommands() + FileCommands() + ViewCommands() + FindCommands() + NavigateCommands() + TasksCommands() + if sourceControlIsEnabled { SourceControlCommands() } + EditorCommands() + ExtensionCommands() + WindowCommands() + } HelpCommands() } } diff --git a/CodeEdit/Features/WindowCommands/EditorCommands.swift b/CodeEdit/Features/WindowCommands/EditorCommands.swift index 6075452006..e99c8dfa36 100644 --- a/CodeEdit/Features/WindowCommands/EditorCommands.swift +++ b/CodeEdit/Features/WindowCommands/EditorCommands.swift @@ -19,12 +19,12 @@ struct EditorCommands: Commands { CommandMenu("Editor") { Menu("Structure") { Button("Move line up") { - editor?.selectedTab?.rangeTranslator?.moveLinesUp() + editor?.selectedTab?.rangeTranslator.moveLinesUp() } .keyboardShortcut("[", modifiers: [.command, .option]) Button("Move line down") { - editor?.selectedTab?.rangeTranslator?.moveLinesDown() + editor?.selectedTab?.rangeTranslator.moveLinesDown() } .keyboardShortcut("]", modifiers: [.command, .option]) } diff --git a/CodeEdit/Features/WindowCommands/TasksCommands.swift b/CodeEdit/Features/WindowCommands/TasksCommands.swift new file mode 100644 index 0000000000..5ea47e6fc5 --- /dev/null +++ b/CodeEdit/Features/WindowCommands/TasksCommands.swift @@ -0,0 +1,114 @@ +// +// TasksCommands.swift +// CodeEdit +// +// Created by Khan Winter on 7/8/25. +// + +import SwiftUI +import Combine + +struct TasksCommands: Commands { + @UpdatingWindowController var windowController: CodeEditWindowController? + + var taskManager: TaskManager? { + windowController?.workspace?.taskManager + } + + @State private var activeTaskStatus: CETaskStatus = .notRunning + @State private var taskManagerListener: AnyCancellable? + @State private var statusListener: AnyCancellable? + + var body: some Commands { + CommandMenu("Tasks") { + let selectedTaskName: String = if let selectedTask = taskManager?.selectedTask { + "\"" + selectedTask.name + "\"" + } else { + "(No Selected Task)" + } + + Button("Run \(selectedTaskName)", systemImage: "play.fill") { + taskManager?.executeActiveTask() + showOutput() + } + .keyboardShortcut("R") + .disabled(taskManager?.selectedTaskID == nil) + + Button("Stop \(selectedTaskName)", systemImage: "stop.fill") { + taskManager?.terminateActiveTask() + } + .keyboardShortcut(".") + .onChange(of: windowController) { _ in + taskManagerListener = taskManager?.objectWillChange.sink { + updateStatusListener() + } + } + .disabled(activeTaskStatus != .running) + + Button("Show \(selectedTaskName) Output") { + showOutput() + } + // Disable when there's no output yet + .disabled(taskManager?.activeTasks[taskManager?.selectedTaskID ?? UUID()] == nil) + + Divider() + + Menu { + if let taskManager { + ForEach(taskManager.availableTasks) { task in + Button(task.name) { + taskManager.selectedTaskID = task.id + } + } + } + + if taskManager?.availableTasks.isEmpty ?? true { + Button("Create Tasks") { + openSettings() + } + } + } label: { + Text("Choose Task...") + } + .disabled(taskManager?.availableTasks.isEmpty == true) + + Button("Manage Tasks...") { + openSettings() + } + .disabled(windowController == nil) + } + } + + /// Update the ``statusListener`` to listen to a potentially new active task. + private func updateStatusListener() { + statusListener?.cancel() + guard let taskManager else { return } + + activeTaskStatus = taskManager.activeTasks[taskManager.selectedTaskID ?? UUID()]?.status ?? .notRunning + guard let id = taskManager.selectedTaskID else { return } + + statusListener = taskManager.activeTasks[id]?.$status.sink { newValue in + activeTaskStatus = newValue + } + } + + private func showOutput() { + guard let utilityAreaModel = windowController?.workspace?.utilityAreaModel else { + return + } + if utilityAreaModel.isCollapsed { + // Open the utility area + utilityAreaModel.isCollapsed.toggle() + } + utilityAreaModel.selectedTab = .debugConsole // Switch to the correct tab + taskManager?.taskShowingOutput = taskManager?.selectedTaskID // Switch to the selected task + } + + private func openSettings() { + NSApp.sendAction( + #selector(CodeEditWindowController.openWorkspaceSettings(_:)), + to: windowController, + from: nil + ) + } +} diff --git a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift b/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift index 288db12e63..ecd717e111 100644 --- a/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift +++ b/CodeEdit/Features/WindowCommands/Utils/WindowControllerPropertyWrapper.swift @@ -36,10 +36,8 @@ struct UpdatingWindowController: DynamicProperty { class WindowControllerBox: ObservableObject { public private(set) weak var controller: CodeEditWindowController? - private var objectWillChangeCancellable: AnyCancellable? - private var utilityAreaCancellable: AnyCancellable? // ``ViewCommands`` needs this. - private var windowCancellable: AnyCancellable? - private var activeEditorCancellable: AnyCancellable? + private var windowCancellable: AnyCancellable? // Needs to stick around between window changes. + private var cancellables: Set = [] init() { windowCancellable = NSApp.publisher(for: \.keyWindow).receive(on: RunLoop.main).sink { [weak self] window in @@ -50,25 +48,32 @@ struct UpdatingWindowController: DynamicProperty { } func setNewController(_ controller: CodeEditWindowController?) { - objectWillChangeCancellable?.cancel() - objectWillChangeCancellable = nil - utilityAreaCancellable?.cancel() - utilityAreaCancellable = nil - activeEditorCancellable?.cancel() - activeEditorCancellable = nil + cancellables.forEach { $0.cancel() } + cancellables.removeAll() self.controller = controller - objectWillChangeCancellable = controller?.objectWillChange.sink { [weak self] in + controller?.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } - utilityAreaCancellable = controller?.workspace?.utilityAreaModel?.objectWillChange.sink { [weak self] in + .store(in: &cancellables) + + controller?.workspace?.utilityAreaModel?.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + let activeEditor = controller?.workspace?.editorManager?.activeEditor - activeEditorCancellable = activeEditor?.objectWillChange.sink { [weak self] in + activeEditor?.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + controller?.workspace?.taskManager?.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + self.objectWillChange.send() } } diff --git a/CodeEdit/Features/WindowCommands/ViewCommands.swift b/CodeEdit/Features/WindowCommands/ViewCommands.swift index 69854c9d5f..c72ccb0710 100644 --- a/CodeEdit/Features/WindowCommands/ViewCommands.swift +++ b/CodeEdit/Features/WindowCommands/ViewCommands.swift @@ -33,6 +33,11 @@ struct ViewCommands: Commands { } .keyboardShortcut("p", modifiers: [.shift, .command]) + Button("Open Search Navigator") { + NSApp.sendAction(#selector(CodeEditWindowController.openSearchNavigator(_:)), to: nil, from: nil) + } + .keyboardShortcut("f", modifiers: [.shift, .command]) + Menu("Font Size") { Button("Increase") { if editorFontSize < 288 { @@ -144,7 +149,7 @@ extension ViewCommands { windowController?.toggleInterface(shouldHide: !isInterfaceHidden) } .disabled(windowController == nil) - .keyboardShortcut(".", modifiers: .command) + .keyboardShortcut("H", modifiers: [.shift, .command]) } } } diff --git a/CodeEdit/ShellIntegration/codeedit_shell_integration.bash b/CodeEdit/ShellIntegration/codeedit_shell_integration.bash index c4cd43b9c2..1cb6f34779 100644 --- a/CodeEdit/ShellIntegration/codeedit_shell_integration.bash +++ b/CodeEdit/ShellIntegration/codeedit_shell_integration.bash @@ -443,14 +443,22 @@ _install_bash_preexec unset -f _install_bash_preexec # -- BEGIN CODEEDIT CUSTOMIZATIONS -- + +__codeedit_status="$?" + __codeedit_preexec() { - echo -ne "\033]0;${1}\007" + builtin printf "\033]0;%s\007" "$1" } __codeedit_precmd() { - echo -ne "\033]0;bash\007" + builtin printf "\033]0;bash\007" } preexec_functions+=(__codeedit_preexec) precmd_functions+=(__codeedit_precmd) + +if [[ "$CE_DISABLE_HISTORY" == "1" ]]; then + unset HISTFILE +fi; + # -- END CODEEDIT CUSTOMIZATIONS -- diff --git a/CodeEdit/ShellIntegration/codeedit_shell_integration_rc.zsh b/CodeEdit/ShellIntegration/codeedit_shell_integration_rc.zsh index 127b346d08..d21bc1dfa9 100644 --- a/CodeEdit/ShellIntegration/codeedit_shell_integration_rc.zsh +++ b/CodeEdit/ShellIntegration/codeedit_shell_integration_rc.zsh @@ -47,16 +47,20 @@ fi builtin autoload -Uz add-zsh-hook __codeedit_preexec() { - echo -n "\033]0;${1}\007" + builtin printf "\033]0;%s\007" "$1" } __codeedit_precmd() { - echo -n "\033]0;zsh\007" + builtin printf "\033]0;zsh\007" } add-zsh-hook preexec __codeedit_preexec add-zsh-hook precmd __codeedit_precmd +if [[ "$CE_DISABLE_HISTORY" == "1" ]]; then + unset HISTFILE +fi + # Fix ZDOTDIR if [[ $USER_ZDOTDIR != $CE_ZDOTDIR ]]; then diff --git a/CodeEdit/Utils/Extensions/Array/Array+Index.swift b/CodeEdit/Utils/Extensions/Array/Array+Index.swift new file mode 100644 index 0000000000..6cafca5ef2 --- /dev/null +++ b/CodeEdit/Utils/Extensions/Array/Array+Index.swift @@ -0,0 +1,16 @@ +// +// Array+Index.swift +// CodeEdit +// +// Created by Abe Malla on 7/24/25. +// + +extension Array { + var second: Element? { + self.count > 1 ? self[1] : nil + } + + var third: Element? { + self.count > 2 ? self[2] : nil + } +} diff --git a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift b/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift index 0d1f2d1056..54ada62530 100644 --- a/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift +++ b/CodeEdit/Utils/Extensions/Date/Date+Formatted.swift @@ -36,4 +36,14 @@ extension Date { return formatter.string(from: self) } + + static var logFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSSS" + return formatter + }() + + func logFormatted() -> String { + Self.logFormatter.string(from: self) + } } diff --git a/CodeEdit/Utils/Extensions/Int/Int+HexString.swift b/CodeEdit/Utils/Extensions/Int/Int+HexString.swift new file mode 100644 index 0000000000..5683227953 --- /dev/null +++ b/CodeEdit/Utils/Extensions/Int/Int+HexString.swift @@ -0,0 +1,28 @@ +// +// Int+HexString.swift +// CodeEdit +// +// Created by Khan Winter on 6/13/25. +// + +extension UInt { + init?(hexString: String) { + // Trim 0x if it's there + let string = String(hexString.trimmingPrefix("0x")) + guard let value = UInt(string, radix: 16) else { + return nil + } + self = value + } +} + +extension Int { + init?(hexString: String) { + // Trim 0x if it's there + let string = String(hexString.trimmingPrefix("0x")) + guard let value = Int(string, radix: 16) else { + return nil + } + self = value + } +} diff --git a/CodeEdit/Utils/Extensions/LocalProcess/LocalProcess+sendText.swift b/CodeEdit/Utils/Extensions/LocalProcess/LocalProcess+sendText.swift new file mode 100644 index 0000000000..c0f0db2011 --- /dev/null +++ b/CodeEdit/Utils/Extensions/LocalProcess/LocalProcess+sendText.swift @@ -0,0 +1,15 @@ +// +// LocalProcess+sendText.swift +// CodeEdit +// +// Created by Khan Winter on 7/15/25. +// + +import SwiftTerm + +extension LocalProcess { + func send(text: String) { + let array = Array(text.utf8) + self.send(data: array[0.. (Process, Pipe) { - var arguments = ["-c"] + var arguments = ["-l", "-c"] arguments.append(contentsOf: args) let task = Process() let pipe = Pipe() diff --git a/CodeEdit/Utils/withTimeout.swift b/CodeEdit/Utils/withTimeout.swift new file mode 100644 index 0000000000..2ebd9b406e --- /dev/null +++ b/CodeEdit/Utils/withTimeout.swift @@ -0,0 +1,46 @@ +// +// TimedOutError.swift +// CodeEdit +// +// Created by Khan Winter on 7/8/25. +// + +struct TimedOutError: Error, Equatable {} + +/// Execute an operation in the current task subject to a timeout. +/// - Warning: This still requires cooperative task cancellation to work correctly. Ensure tasks opt +/// into cooperative cancellation. +/// - Parameters: +/// - duration: The duration to wait until timing out. Uses a continuous clock. +/// - operation: The async operation to perform. +/// - Returns: Returns the result of `operation` if it completed in time. +/// - Throws: Throws ``TimedOutError`` if the timeout expires before `operation` completes. +/// If `operation` throws an error before the timeout expires, that error is propagated to the caller. +public func withTimeout( + duration: Duration, + onTimeout: @escaping @Sendable () async throws -> Void = { }, + operation: @escaping @Sendable () async throws -> R +) async throws -> R { + return try await withThrowingTaskGroup(of: R.self) { group in + let deadline: ContinuousClock.Instant = .now + duration + + // Start actual work. + group.addTask { + return try await operation() + } + // Start timeout child task. + group.addTask { + if .now > deadline { + try await Task.sleep(until: deadline) // sleep until the deadline + } + try Task.checkCancellation() + // We’ve reached the timeout. + try await onTimeout() + throw TimedOutError() + } + // First finished child task wins, cancel the other task. + let result = try await group.next()! + group.cancelAll() + return result + } +} diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index d9e2aa1b0e..b52159d1ed 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -44,84 +44,47 @@ struct WorkspaceView: View { VStack { SplitViewReader { proxy in SplitView(axis: .vertical) { - ZStack { - GeometryReader { geo in - EditorLayoutView( - layout: editorManager.isFocusingActiveEditor - ? editorManager.activeEditor.getEditorLayout() ?? editorManager.editorLayout - : editorManager.editorLayout, - focus: $focusedEditor - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: geo.size.height) { newHeight in - editorsHeight = newHeight - } - .onAppear { - editorsHeight = geo.size.height - } - } - } - .frame(minHeight: 170 + 29 + 29) - .collapsable() - .collapsed($utilityAreaViewModel.isMaximized) - .holdingPriority(.init(1)) - Rectangle() - .collapsable() - .collapsed($utilityAreaViewModel.isCollapsed) - .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) - .opacity(0) - .frame(idealHeight: 260) - .frame(minHeight: 100) - .background { - GeometryReader { geo in - Rectangle() - .opacity(0) - .onChange(of: geo.size.height) { newHeight in - drawerHeight = newHeight - } - .onAppear { - drawerHeight = geo.size.height - } - } - } - .accessibilityHidden(true) + editorArea + utilityAreaPlaceholder } .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { - ZStack(alignment: .top) { - UtilityAreaView() - .frame(height: utilityAreaViewModel.isMaximized ? nil : drawerHeight) - .frame(maxHeight: utilityAreaViewModel.isMaximized ? .infinity : nil) - .padding(.top, utilityAreaViewModel.isMaximized ? statusbarHeight + 1 : 0) - .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight + 1) - VStack(spacing: 0) { - StatusBarView(proxy: proxy) - if utilityAreaViewModel.isMaximized { - PanelDivider() - } - } - .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight - statusbarHeight) - } - .accessibilityElement(children: .contain) + utilityArea(proxy: proxy) } .overlay(alignment: .topTrailing) { NotificationPanelView() } + + // MARK: - Tab Focus Listeners + + .onChange(of: editorManager.activeEditor) { newValue in + focusedEditor = newValue + } .onChange(of: focusedEditor) { newValue in - /// update active tab group only if the new one is not the same with it. + /// Update active tab group only if the new one is not the same with it. if let newValue, editorManager.activeEditor != newValue { editorManager.activeEditor = newValue } } - .onChange(of: editorManager.activeEditor) { newValue in - if newValue != focusedEditor { - focusedEditor = newValue - } - } + + // MARK: - Theme Color Scheme + .task { themeModel.colorScheme = colorScheme + } + .onChange(of: colorScheme) { newValue in + themeModel.colorScheme = newValue + if matchAppearance { + themeModel.selectedTheme = newValue == .dark + ? themeModel.selectedDarkTheme + : themeModel.selectedLightTheme + } + } + // MARK: - Source Control + + .task { do { try await sourceControlManager.refreshRemotes() try await sourceControlManager.refreshStashEntries() @@ -132,14 +95,6 @@ struct WorkspaceView: View { ) } } - .onChange(of: colorScheme) { newValue in - themeModel.colorScheme = newValue - if matchAppearance { - themeModel.selectedTheme = newValue == .dark - ? themeModel.selectedDarkTheme - : themeModel.selectedLightTheme - } - } .onChange(of: sourceControlIsEnabled) { newValue in if newValue { Task { @@ -149,17 +104,9 @@ struct WorkspaceView: View { sourceControlManager.currentBranch = nil } } - .onChange(of: focusedEditor) { newValue in - /// Update active tab group only if the new one is not the same with it. - if let newValue, editorManager.activeEditor != newValue { - editorManager.activeEditor = newValue - } - } - .onChange(of: editorManager.activeEditor) { newValue in - if newValue != focusedEditor { - focusedEditor = newValue - } - } + + // MARK: - Window Will Close + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { output in if let window = output.object as? NSWindow, self.window == window { workspace.addToWorkspaceState( @@ -181,6 +128,76 @@ struct WorkspaceView: View { } } + // MARK: - Editor Area + + @ViewBuilder private var editorArea: some View { + ZStack { + GeometryReader { geo in + EditorLayoutView( + layout: editorManager.isFocusingActiveEditor + ? editorManager.activeEditor.getEditorLayout() ?? editorManager.editorLayout + : editorManager.editorLayout, + focus: $focusedEditor + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: geo.size.height) { newHeight in + editorsHeight = newHeight + } + .onAppear { + editorsHeight = geo.size.height + } + } + } + .frame(minHeight: 170 + 29 + 29) + .collapsable() + .collapsed($utilityAreaViewModel.isMaximized) + .holdingPriority(.init(1)) + } + + // MARK: - Utility Area + + @ViewBuilder + private func utilityArea(proxy: SplitViewProxy) -> some View { + ZStack(alignment: .top) { + UtilityAreaView() + .frame(height: utilityAreaViewModel.isMaximized ? nil : drawerHeight) + .frame(maxHeight: utilityAreaViewModel.isMaximized ? .infinity : nil) + .padding(.top, utilityAreaViewModel.isMaximized ? statusbarHeight + 1 : 0) + .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight + 1) + VStack(spacing: 0) { + StatusBarView(proxy: proxy) + if utilityAreaViewModel.isMaximized { + PanelDivider() + } + } + .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight - statusbarHeight) + } + .accessibilityElement(children: .contain) + } + + @ViewBuilder private var utilityAreaPlaceholder: some View { + Rectangle() + .collapsable() + .collapsed($utilityAreaViewModel.isCollapsed) + .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) + .opacity(0) + .frame(idealHeight: 260) + .frame(minHeight: 100) + .background { + GeometryReader { geo in + Rectangle() + .opacity(0) + .onChange(of: geo.size.height) { newHeight in + drawerHeight = newHeight + } + .onAppear { + drawerHeight = geo.size.height + } + } + } + .accessibilityHidden(true) + } + private func handleDrop(providers: [NSItemProvider]) -> Bool { for provider in providers { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in diff --git a/CodeEditTestPlan.xctestplan b/CodeEditTestPlan.xctestplan index 66a20ae7ed..1b4e456b21 100644 --- a/CodeEditTestPlan.xctestplan +++ b/CodeEditTestPlan.xctestplan @@ -31,6 +31,7 @@ "CodeEditUIUnitTests\/testSegmentedControlLight()", "CodeEditUIUnitTests\/testSegmentedControlProminentDark()", "CodeEditUIUnitTests\/testSegmentedControlProminentLight()", + "RegistryTests", "WelcomeModuleUnitTests", "WelcomeModuleUnitTests\/testRecentJSFileDarkSnapshot()", "WelcomeModuleUnitTests\/testRecentJSFileLightSnapshot()", diff --git a/CodeEditTests/Features/Documents/CodeFileDocument+UTTypeTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift similarity index 100% rename from CodeEditTests/Features/Documents/CodeFileDocument+UTTypeTests.swift rename to CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift diff --git a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift new file mode 100644 index 0000000000..b5b8fc0408 --- /dev/null +++ b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift @@ -0,0 +1,103 @@ +// +// CodeFileDocumentTests.swift +// CodeEditModules/CodeFileTests +// +// Created by Marco Carnevali on 18/03/22. +// + +import Foundation +import SwiftUI +import Testing +@testable import CodeEdit + +@Suite +struct CodeFileDocumentTests { + let defaultString = "func test() { }" + + private func withFile(_ operation: (URL) throws -> Void) throws { + try withTempDir { dir in + let fileURL = dir.appending(path: "file.swift") + try operation(fileURL) + } + } + + private func withCodeFile(_ operation: (CodeFileDocument) throws -> Void) throws { + try withFile { fileURL in + try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) + let codeFile = try CodeFileDocument(contentsOf: fileURL, ofType: "public.source-code") + try operation(codeFile) + } + } + + @Test + func testLoadUTF8Encoding() throws { + try withFile { fileURL in + try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) + let codeFile = try CodeFileDocument( + for: fileURL, + withContentsOf: fileURL, + ofType: "public.source-code" + ) + #expect(codeFile.content?.string == defaultString) + #expect(codeFile.sourceEncoding == .utf8) + } + } + + @Test + func testWriteUTF8Encoding() throws { + try withFile { fileURL in + let codeFile = CodeFileDocument() + codeFile.content = NSTextStorage(string: defaultString) + codeFile.sourceEncoding = .utf8 + try codeFile.write(to: fileURL, ofType: "public.source-code") + + let data = try Data(contentsOf: fileURL) + var nsString: NSString? + let fileEncoding = NSString.stringEncoding( + for: data, + encodingOptions: [ + .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, + .useOnlySuggestedEncodingsKey: true + ], + convertedString: &nsString, + usedLossyConversion: nil + ) + + #expect(codeFile.content?.string as NSString? == nsString) + #expect(fileEncoding == NSUTF8StringEncoding) + } + } + + @Test + func ignoresExternalUpdatesWithOutstandingChanges() throws { + try withCodeFile { codeFile in + // Mark the file dirty + codeFile.updateChangeCount(.changeDone) + + // Update the modification date + try "different contents".write(to: codeFile.fileURL!, atomically: true, encoding: .utf8) + + // Tell the file the disk representation changed + codeFile.presentedItemDidChange() + + // The file should not have reloaded + #expect(codeFile.content?.string == defaultString) + #expect(codeFile.isDocumentEdited == true) + } + } + + @Test + func loadsExternalUpdatesWithNoOutstandingChanges() throws { + try withCodeFile { codeFile in + // Update the modification date + try "different contents".write(to: codeFile.fileURL!, atomically: true, encoding: .utf8) + + // Tell the file the disk representation changed + codeFile.presentedItemDidChange() + + // The file should have reloaded (it was clean) + #expect(codeFile.content?.string == "different contents") + #expect(codeFile.isDocumentEdited == false) + } + } +} diff --git a/CodeEditTests/Features/CodeFile/CodeFileTests.swift b/CodeEditTests/Features/CodeFile/CodeFileTests.swift deleted file mode 100644 index 20d747b2f7..0000000000 --- a/CodeEditTests/Features/CodeFile/CodeFileTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// UnitTests.swift -// CodeEditModules/CodeFileTests -// -// Created by Marco Carnevali on 18/03/22. -// - -import Foundation -import SwiftUI -import XCTest -@testable import CodeEdit - -final class CodeFileUnitTests: XCTestCase { - var fileURL: URL! - - override func setUp() async throws { - let directory = try FileManager.default.url( - for: .developerApplicationDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appending(path: "CodeEdit", directoryHint: .isDirectory) - .appending(path: "WorkspaceClientTests", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - fileURL = directory.appending(path: "fakeFile.swift") - } - - func testLoadUTF8Encoding() throws { - let fileContent = "func test(){}" - - try fileContent.data(using: .utf8)?.write(to: fileURL) - let codeFile = try CodeFileDocument( - for: fileURL, - withContentsOf: fileURL, - ofType: "public.source-code" - ) - XCTAssertEqual(codeFile.content?.string, fileContent) - XCTAssertEqual(codeFile.sourceEncoding, .utf8) - } - - func testWriteUTF8Encoding() throws { - let codeFile = CodeFileDocument() - codeFile.content = NSTextStorage(string: "func test(){}") - codeFile.sourceEncoding = .utf8 - try codeFile.write(to: fileURL, ofType: "public.source-code") - - let data = try Data(contentsOf: fileURL) - var nsString: NSString? - let fileEncoding = NSString.stringEncoding( - for: data, - encodingOptions: [ - .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, - .useOnlySuggestedEncodingsKey: true - ], - convertedString: &nsString, - usedLossyConversion: nil - ) - - XCTAssertEqual(codeFile.content?.string as NSString?, nsString) - XCTAssertEqual(fileEncoding, NSUTF8StringEncoding) - } -} diff --git a/CodeEditTests/Features/Documents/DocumentsUnitTests.swift b/CodeEditTests/Features/Documents/DocumentsUnitTests.swift index 5c9fb5b668..39f47901b8 100644 --- a/CodeEditTests/Features/Documents/DocumentsUnitTests.swift +++ b/CodeEditTests/Features/Documents/DocumentsUnitTests.swift @@ -8,6 +8,7 @@ import XCTest @testable import CodeEdit +@MainActor final class DocumentsUnitTests: XCTestCase { // Properties private var splitViewController: CodeEditSplitViewController! @@ -22,7 +23,7 @@ final class DocumentsUnitTests: XCTestCase { super.setUp() hapticFeedbackPerformerMock = NSHapticFeedbackPerformerMock() navigatorViewModel = .init() - workspace.taskManager = TaskManager(workspaceSettings: CEWorkspaceSettingsData()) + workspace.taskManager = TaskManager(workspaceSettings: CEWorkspaceSettingsData(), workspaceURL: nil) window = NSWindow() splitViewController = .init( workspace: workspace, diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift index 72fd990db1..34209faea0 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift @@ -8,8 +8,8 @@ import XCTest @testable import CodeEdit -// swiftlint:disable:next type_body_length -final class FindAndReplaceTests: XCTestCase { +@MainActor +final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_body_length private var directory: URL! private var files: [CEWorkspaceFile] = [] private var mockWorkspace: WorkspaceDocument! @@ -34,8 +34,8 @@ final class FindAndReplaceTests: XCTestCase { try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - mockWorkspace = try await WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") - searchState = await mockWorkspace.searchState + mockWorkspace = try WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") + searchState = mockWorkspace.searchState // Add a few files let folder1 = directory.appending(path: "Folder 2") @@ -64,7 +64,7 @@ final class FindAndReplaceTests: XCTestCase { files[1].parent = folder1File files[2].parent = folder2File - await mockWorkspace.searchState?.addProjectToIndex() + mockWorkspace.searchState?.addProjectToIndex() // NOTE: This is a temporary solution. In the future, a file watcher should track file updates // and trigger an index update. diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift index 1c3de542b8..61ddfb2bbd 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift @@ -139,58 +139,38 @@ final class FindTests: XCTestCase { /// Tests the search functionality of the `WorkspaceDocument.SearchState` and `SearchIndexer`. func testSearch() async { - let searchExpectation = XCTestExpectation(description: "Search for 'Ipsum'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'asperiores'") - - Task { - await searchState.search("Ipsum") - searchExpectation.fulfill() - } - + await searchState.search("Ipsum") // Wait for the first search expectation to be fulfilled - await fulfillment(of: [searchExpectation], timeout: 10) - // Retrieve the search results after the first search - let searchResults = searchState.searchResult - - XCTAssertEqual(searchResults.count, 2) - - Task { - await searchState.search("asperiores") - searchExpectation2.fulfill() + await waitForExpectation { + searchState.searchResult.count == 2 + } onTimeout: { + XCTFail("Search state did not find two results.") } - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - XCTAssertEqual(searchResults2.count, 1) + await searchState.search("asperiores") + await waitForExpectation { + searchState.searchResult.count == 1 + } onTimeout: { + XCTFail("Search state did not find correct results.") + } } /// Checks if the search still returns proper results, /// if the search term isn't a complete word func testSearchWithOptionContaining() async { - let searchExpectation = XCTestExpectation(description: "Search for 'psu'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'erio'") - - Task { - await searchState.search("psu") - searchExpectation.fulfill() + await searchState.search("psu") + await waitForExpectation { + searchState.searchResult.count == 2 + } onTimeout: { + XCTFail("Search state did not find two results.") } - await fulfillment(of: [searchExpectation], timeout: 10) - - let searchResults = searchState.searchResult - - XCTAssertEqual(searchResults.count, 2) - - Task { - await searchState.search("erio") - searchExpectation2.fulfill() + await searchState.search("erio") + await waitForExpectation { + searchState.searchResult.count == 1 + } onTimeout: { + XCTFail("Search state did not find correct results.") } - - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - XCTAssertEqual(searchResults2.count, 1) } /// This test verifies the accuracy of the word search feature. @@ -198,121 +178,81 @@ final class FindTests: XCTestCase { /// Following that, it examines the occurrence of the fragment 'perior,' /// which is not a complete word in any of the documents func testSearchWithOptionMatchingWord() async { - let searchExpectation = XCTestExpectation(description: "Search for 'Ipsum'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'perior'") - // Set the search option to 'Matching Word' searchState.selectedMode[2] = .MatchingWord - Task { - await searchState.search("Ipsum") - searchExpectation.fulfill() + await searchState.search("Ipsum") + await waitForExpectation { + searchState.searchResult.count == 2 + } onTimeout: { + XCTFail("Search state did not find correct results.") } - await fulfillment(of: [searchExpectation], timeout: 10) - - let searchResults = searchState.searchResult - - XCTAssertEqual(searchResults.count, 2) - // Check if incomplete words return no search results. - Task { - await searchState.search("perior") - searchExpectation2.fulfill() + await searchState.search("perior") + await waitForExpectation { + searchState.searchResult.isEmpty + } onTimeout: { + XCTFail("Search state did not find correct results.") } - - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - XCTAssertEqual(searchResults2.count, 0) } func testSearchWithOptionStartingWith() async { - let searchExpectation = XCTestExpectation(description: "Search for 'Ip'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'res'") - // Set the search option to 'Starting With' searchState.selectedMode[2] = .StartingWith - Task { - await searchState.search("Ip") - searchExpectation.fulfill() + await searchState.search("Ip") + await waitForExpectation { + searchState.searchResult.count == 2 + } onTimeout: { + XCTFail("Search state did not find two results.") } - await fulfillment(of: [searchExpectation], timeout: 10) - - let searchResults = searchState.searchResult - - XCTAssertEqual(searchResults.count, 2) - - Task { - await searchState.search("res") - searchExpectation2.fulfill() + await searchState.search("res") + await waitForExpectation { + searchState.searchResult.isEmpty + } onTimeout: { + XCTFail("Search state did not find two results.") } - - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - XCTAssertEqual(searchResults2.count, 0) } func testSearchWithOptionEndingWith() async { - let searchExpectation = XCTestExpectation(description: "Search for 'um'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'res'") - // Set the search option to 'Ending with' searchState.selectedMode[2] = .EndingWith - Task { - await searchState.search("um") - searchExpectation.fulfill() + await searchState.search("um") + await waitForExpectation { + searchState.searchResult.count == 2 + } onTimeout: { + XCTFail("Search state did not find two results.") } - await fulfillment(of: [searchExpectation], timeout: 10) - - let searchResults = searchState.searchResult - - XCTAssertEqual(searchResults.count, 2) - - Task { - await searchState.search("asperi") - searchExpectation2.fulfill() + await searchState.search("asperi") + await waitForExpectation { + searchState.searchResult.isEmpty + } onTimeout: { + XCTFail("Search state did not find correct results.") } - - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - XCTAssertEqual(searchResults2.count, 0) } func testSearchWithOptionCaseSensitive() async { - let searchExpectation = XCTestExpectation(description: "Search for 'Ipsum'") - let searchExpectation2 = XCTestExpectation(description: "Search for 'asperiores'") - searchState.caseSensitive = true - Task { - await searchState.search("ipsum") - searchExpectation.fulfill() - } - + await searchState.search("ipsum") // Wait for the first search expectation to be fulfilled - await fulfillment(of: [searchExpectation], timeout: 10) - // Retrieve the search results after the first search - let searchResults = searchState.searchResult - - // Expecting a result count of 0 due to the intentional use of a lowercase 'i' - XCTAssertEqual(searchResults.count, 0) - - Task { - await searchState.search("Asperiores") - searchExpectation2.fulfill() + await waitForExpectation { + // Expecting a result count of 0 due to the intentional use of a lowercase 'i' + searchState.searchResult.isEmpty + } onTimeout: { + XCTFail("Search state did not find correct results.") } - await fulfillment(of: [searchExpectation2], timeout: 10) - let searchResults2 = searchState.searchResult - - // Anticipating zero results since the search is case-sensitive and we used an uppercase 'A' - XCTAssertEqual(searchResults2.count, 0) + await searchState.search("Asperiores") + await waitForExpectation { + // Anticipating zero results since the search is case-sensitive and we used an uppercase 'A' + searchState.searchResult.isEmpty + } onTimeout: { + XCTFail("Search state did not find correct results.") + } } // Not implemented yet diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift new file mode 100644 index 0000000000..a97363fc68 --- /dev/null +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -0,0 +1,75 @@ +// +// EditorStateRestorationTests.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +import Testing +import Foundation +@testable import CodeEdit + +@Suite +struct EditorStateRestorationTests { + @Test + func createsDatabase() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + _ = try EditorStateRestoration(url) + #expect(FileManager.default.fileExists(atPath: url.path(percentEncoded: false))) + } + } + + @Test + func savesAndRetrievesStateForFile() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + let restoration = try EditorStateRestoration(url) + + // Update some state + restoration.updateRestorationState( + for: dir.appending(path: "file.txt"), + data: .init(cursorPositions: [], scrollPosition: .zero) + ) + + // Retrieve it + #expect( + restoration.restorationState(for: dir.appending(path: "file.txt")) + == EditorStateRestoration.StateRestorationData(cursorPositions: [], scrollPosition: .zero) + ) + } + } + + @Test + func savesScrollPosition() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + let restoration = try EditorStateRestoration(url) + + // Update some state + restoration.updateRestorationState( + for: dir.appending(path: "file.txt"), + data: .init(cursorPositions: [], scrollPosition: CGPoint(x: 100, y: 100)) + ) + + // Retrieve it + #expect( + restoration.restorationState(for: dir.appending(path: "file.txt")) + == EditorStateRestoration.StateRestorationData( + cursorPositions: [], + scrollPosition: CGPoint(x: 100, y: 100) + ) + ) + } + } + + @Test + func clearsCorruptedDatabase() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + try "bad data".write(to: url, atomically: true, encoding: .utf8) + // This will throw if it can't connect to the database. + _ = try EditorStateRestoration(url) + } + } +} diff --git a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift new file mode 100644 index 0000000000..cfa9619aaf --- /dev/null +++ b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift @@ -0,0 +1,36 @@ +// +// UndoManagerRegistrationTests.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +@testable import CodeEdit +import Testing +import Foundation +import CodeEditTextView + +@MainActor +@Suite +struct UndoManagerRegistrationTests { + let registrar = UndoManagerRegistration() + let file = CEWorkspaceFile(url: URL(filePath: "/fake/dir/file.txt")) + let textView = TextView(string: "hello world") + + @Test + func newUndoManager() { + let manager = registrar.manager(forFile: file) + #expect(manager.canUndo == false) + } + + @Test + func undoManagersRetained() throws { + let manager = registrar.manager(forFile: file) + textView.setUndoManager(manager) + manager.registerMutation(.init(insert: "hello", at: 0, limit: 11)) + + let sameManager = registrar.manager(forFile: file) + #expect(manager === sameManager) + #expect(sameManager.canUndo) + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index 236f2a7215..7112ccec8a 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -69,8 +69,10 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { server: bufferingConnection, initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: tempTestDir.path()) ), + lspPid: -1, serverCapabilities: capabilities, - rootPath: tempTestDir + rootPath: tempTestDir, + logContainer: LanguageServerLogContainer(language: .swift) ) _ = try await server.lspInstance.initializeIfNeeded() return (connection: bufferingConnection, server: server) @@ -230,13 +232,13 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { let (connection, server) = try await makeTestServer() // Create a CodeFileDocument to test with, attach it to the workspace and file let codeFile = try await openCodeFile(for: server, connection: connection, file: file, syncOption: option) - XCTAssertNotNil(server.openFiles.contentCoordinator(for: codeFile)) - server.openFiles.contentCoordinator(for: codeFile)?.setUpUpdatesTask() + XCTAssertNotNil(codeFile.languageServerObjects.textCoordinator.languageServer) + codeFile.languageServerObjects.textCoordinator.setUpUpdatesTask() codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) let textView = TextView(string: "") textView.setTextStorage(codeFile.content!) - textView.delegate = server.openFiles.contentCoordinator(for: codeFile) + textView.delegate = codeFile.languageServerObjects.textCoordinator textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") @@ -288,13 +290,13 @@ final class LanguageServerCodeFileDocumentTests: XCTestCase { let (connection, server) = try await makeTestServer() let codeFile = try await openCodeFile(for: server, connection: connection, file: file, syncOption: option) - XCTAssertNotNil(server.openFiles.contentCoordinator(for: codeFile)) - server.openFiles.contentCoordinator(for: codeFile)?.setUpUpdatesTask() + XCTAssertNotNil(codeFile.languageServerObjects.textCoordinator.languageServer) + codeFile.languageServerObjects.textCoordinator.setUpUpdatesTask() codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) let textView = TextView(string: "") textView.setTextStorage(codeFile.content!) - textView.delegate = server.openFiles.contentCoordinator(for: codeFile) + textView.delegate = codeFile.languageServerObjects.textCoordinator textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift index 76b2e8cf3c..9b7738a7d8 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -49,8 +49,10 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { server: BufferingServerConnection(), initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: "/") ), + lspPid: -1, serverCapabilities: capabilities, - rootPath: URL(fileURLWithPath: "") + rootPath: URL(fileURLWithPath: ""), + logContainer: LanguageServerLogContainer(language: .swift) ) _ = try await server.lspInstance.initializeIfNeeded() document = MockDocumentType() @@ -74,7 +76,7 @@ final class LanguageServerDocumentObjectsTests: XCTestCase { XCTAssertNotNil(server.openFiles.document(for: languageServerURI)) try await server.closeDocument(languageServerURI) - XCTAssertNil(document.languageServerObjects.highlightProvider) - XCTAssertNil(document.languageServerObjects.textCoordinator) + XCTAssertNil(document.languageServerObjects.highlightProvider.languageServer) + XCTAssertNil(document.languageServerObjects.textCoordinator.languageServer) } } diff --git a/CodeEditTests/Features/LSP/Registry.swift b/CodeEditTests/Features/LSP/Registry.swift new file mode 100644 index 0000000000..a450e67b12 --- /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 + +@MainActor +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") + } + } +} diff --git a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift index 4ecc58fac9..dda948257c 100644 --- a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift +++ b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift @@ -5,16 +5,16 @@ // Created by Tommy Ludwig on 08.07.24. // -import XCTest +import Testing @testable import CodeEdit -final class CEActiveTaskTests: XCTestCase { - var task: CETask! - var activeTask: CEActiveTask! - - override func setUpWithError() throws { - try super.setUpWithError() +@MainActor +@Suite(.serialized) +class CEActiveTaskTests { + var task: CETask + var activeTask: CEActiveTask + init() { task = CETask( name: "Test Task", command: "echo $STATE", @@ -23,76 +23,65 @@ final class CEActiveTaskTests: XCTestCase { activeTask = CEActiveTask(task: task) } - override func tearDownWithError() throws { - task = nil - activeTask = nil - try super.tearDownWithError() - } - + @Test func testInitialization() throws { - XCTAssertEqual(activeTask.task, task, "Active task should be initialized with the provided CETask.") + #expect(activeTask.task == task, "Active task should be initialized with the provided CETask.") } - func testRunMethod() { - activeTask.run() - activeTask.process?.waitUntilExit() - - let testExpectation = XCTestExpectation() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - XCTAssertEqual(self.activeTask.status, .finished) - XCTAssertTrue(self.activeTask.output.contains("Testing")) - testExpectation.fulfill() + @Test(arguments: [Shell.zsh, Shell.bash]) + func testRunMethod(_ shell: Shell) async throws { + activeTask.run(workspaceURL: nil, shell: shell) + await waitForExpectation(timeout: .seconds(10)) { + activeTask.status == .running + } onTimeout: { + Issue.record("Task never started. \(activeTask.status)") } - wait(for: [testExpectation], timeout: 1) - } + activeTask.waitForExit() - // the renew method is needed because a Process can only be run once - func testRenewMethod() { - activeTask.run() - let testExpectation1 = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - testExpectation1.fulfill() + await waitForExpectation(timeout: .seconds(10)) { + activeTask.status == .finished + } onTimeout: { + Issue.record("Status never changed to finished. \(activeTask.status)") } - wait(for: [testExpectation1], timeout: 1) - - activeTask.renew() - activeTask.run() - let testExpectation2 = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertEqual(self.activeTask.status, .finished) - testExpectation2.fulfill() - } - wait(for: [testExpectation2], timeout: 1) + let output = try #require(activeTask.output) + #expect(output.getBufferAsString().contains("Testing")) } - func testHandleProcessFinished() { + @Test(arguments: [Shell.zsh, Shell.bash]) + func testHandleProcessFinished(_ shell: Shell) async throws { task.command = "aNon-existentCommand" - activeTask.run() - let testExpectation1 = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertEqual(self.activeTask.status, .failed) - testExpectation1.fulfill() + activeTask.run(workspaceURL: nil, shell: shell) + activeTask.waitForExit() + + await waitForExpectation(timeout: .seconds(10)) { + activeTask.status == .failed + } onTimeout: { + Issue.record("Status never changed to failed. \(activeTask.status)") } - wait(for: [testExpectation1], timeout: 1) } - func testClearOutput() { - activeTask.run() - let testExpectation1 = XCTestExpectation() - Task { - await activeTask.clearOutput() - testExpectation1.fulfill() + @Test(arguments: [Shell.zsh, Shell.bash]) + func testClearOutput(_ shell: Shell) async throws { + activeTask.run(workspaceURL: nil, shell: shell) + activeTask.waitForExit() + + await waitForExpectation { + activeTask.status == .finished + } onTimeout: { + Issue.record("Status never changed to finished.") + } + + #expect( + activeTask.output?.getBufferAsString().isEmpty == false, + "Task output should not be empty after task completion." + ) + activeTask.clearOutput() + + await waitForExpectation { + activeTask.output?.getBufferAsString().isEmpty == true + } onTimeout: { + Issue.record("Task output should be empty after clearOutput is called.") } - wait(for: [testExpectation1], timeout: 1) - XCTAssertTrue(activeTask.output.isEmpty) } -// func testClearOutputMethod() async { -// // Assuming the task generates some output -// await activeTask.run() -// XCTAssertFalse(activeTask.output.isEmpty, "Task output should not be empty after task completion.") -// await activeTask.clearOutput() -// XCTAssertTrue(activeTask.output.isEmpty, "Task output should be empty after clearOutput is called.") -// } } diff --git a/CodeEditTests/Features/Tasks/TaskManagerTests.swift b/CodeEditTests/Features/Tasks/TaskManagerTests.swift index 3b1abbf23c..898c863f0f 100644 --- a/CodeEditTests/Features/Tasks/TaskManagerTests.swift +++ b/CodeEditTests/Features/Tasks/TaskManagerTests.swift @@ -5,126 +5,114 @@ // Created by Tommy Ludwig on 08.07.24. // -import XCTest +import Foundation +import Testing @testable import CodeEdit -final class TaskManagerTests: XCTestCase { +@MainActor +@Suite +class TaskManagerTests { var taskManager: TaskManager! var mockWorkspaceSettings: CEWorkspaceSettingsData! - override func setUp() { - super.setUp() - - do { - let workspaceSettings = try JSONDecoder().decode(CEWorkspaceSettingsData.self, from: Data("{}".utf8)) - mockWorkspaceSettings = workspaceSettings - } catch { - XCTFail("Error decoding JSON: \(error.localizedDescription)") - } - - taskManager = TaskManager(workspaceSettings: mockWorkspaceSettings) - } - - override func tearDown() { - taskManager = nil - mockWorkspaceSettings = nil - super.tearDown() + init() throws { + let workspaceSettings = try JSONDecoder().decode(CEWorkspaceSettingsData.self, from: Data("{}".utf8)) + mockWorkspaceSettings = workspaceSettings + taskManager = TaskManager(workspaceSettings: mockWorkspaceSettings, workspaceURL: nil) } func testInitialization() { - XCTAssertNotNil(taskManager) - XCTAssertEqual(taskManager.availableTasks, mockWorkspaceSettings.tasks) + #expect(taskManager != nil) + #expect(taskManager.availableTasks == mockWorkspaceSettings.tasks) } - func testExecuteSelectedTask() { + @Test(arguments: [SettingsData.TerminalShell.zsh, SettingsData.TerminalShell.bash]) + func executeSelectedTask(_ shell: SettingsData.TerminalShell) async throws { + Settings.shared.preferences.terminal.shell = shell + let task = CETask(name: "Test Task", command: "echo 'Hello World'") mockWorkspaceSettings.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() - let testExpectation = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertTrue(((self.taskManager.activeTasks[task.id]?.output.contains("Hello World")) != nil)) - testExpectation.fulfill() + await waitForExpectation(timeout: .seconds(10)) { + self.taskManager.activeTasks[task.id]?.status == .finished + } onTimeout: { + Issue.record("Status never changed to finished.") } - wait(for: [testExpectation], timeout: 1) + + let outputString = try #require(taskManager.activeTasks[task.id]?.output?.getBufferAsString()) + #expect(outputString.contains("Hello World")) } - func testTerminateSelectedTask() { - let task = CETask(name: "Test Task", command: "sleep 1") + @Test(.disabled("Not sure why but tasks run in shells seem to never receive signals.")) + func terminateSelectedTask() async throws { + let task = CETask(name: "Test Task", command: "sleep 10") mockWorkspaceSettings.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() - let testExpectation1 = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .running) - self.taskManager.terminateActiveTask() - testExpectation1.fulfill() + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .running + } onTimeout: { + Issue.record("Task did not run") } - wait(for: [testExpectation1], timeout: 1) + taskManager.terminateActiveTask() - let testExpectation2 = XCTestExpectation() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .notRunning) - testExpectation2.fulfill() + await waitForExpectation(timeout: .seconds(10)) { + taskManager.taskStatus(taskID: task.id) == .notRunning + } onTimeout: { + Issue.record("Task did not terminate. \(taskManager.taskStatus(taskID: task.id))") } - - wait(for: [testExpectation2], timeout: 1) } // This test verifies the functionality of suspending and resuming a task. // It ensures that suspend signals do not stack up, // meaning only one resume signal is required to resume the task, // regardless of the number of times `suspendTask()` is called. - func testSuspendAndResumeTask() { - let task = CETask(name: "Test Task", command: "sleep 1") + @Test(.disabled("Not sure why but tasks run in shells seem to never receive signals.")) + func suspendAndResumeTask() async throws { + let task = CETask(name: "Test Task", command: "sleep 5") mockWorkspaceSettings.tasks.append(task) taskManager.selectedTaskID = task.id taskManager.executeActiveTask() - let suspendExpectation = XCTestExpectation(description: "Suspend task after execution") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.taskManager.suspendTask(taskID: task.id) - suspendExpectation.fulfill() + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .running + } onTimeout: { + Issue.record("Task did not start running.") } - wait(for: [suspendExpectation], timeout: 1) - - let verifySuspensionExpectation = XCTestExpectation(description: "Verify task is suspended and resume it") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertEqual(self.taskManager.activeTasks[task.id]?.process?.isRunning, true) - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .stopped) - self.taskManager.resumeTask(taskID: task.id) - verifySuspensionExpectation.fulfill() + taskManager.suspendTask(taskID: task.id) + + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .stopped + } onTimeout: { + Issue.record("Task did not suspend") } - wait(for: [verifySuspensionExpectation], timeout: 1) - - let multipleSuspensionsExpectation = XCTestExpectation(description: "Suspend task multiple times") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .running) - self.taskManager.suspendTask(taskID: task.id) - self.taskManager.suspendTask(taskID: task.id) - self.taskManager.suspendTask(taskID: task.id) - multipleSuspensionsExpectation.fulfill() + taskManager.resumeTask(taskID: task.id) + + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .running + } onTimeout: { + Issue.record("Task did not resume") } - wait(for: [multipleSuspensionsExpectation], timeout: 1) - - let verifySingleResumeExpectation = XCTestExpectation( - description: "Verify task is suspended and resume it once" - ) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .stopped) - self.taskManager.resumeTask(taskID: task.id) - verifySingleResumeExpectation.fulfill() + + taskManager.suspendTask(taskID: task.id) + taskManager.suspendTask(taskID: task.id) + taskManager.suspendTask(taskID: task.id) + + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .stopped + } onTimeout: { + Issue.record("Task did not suspend after multiple suspend messages.") } - wait(for: [verifySingleResumeExpectation], timeout: 1) + taskManager.resumeTask(taskID: task.id) - let finalRunningStateExpectation = XCTestExpectation(description: "Verify task is running after single resume") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - XCTAssertEqual(self.taskManager.taskStatus(taskID: task.id), .running) - finalRunningStateExpectation.fulfill() + await waitForExpectation { + taskManager.taskStatus(taskID: task.id) == .running + } onTimeout: { + Issue.record("Task did not resume after multiple suspend messages.") } - wait(for: [finalRunningStateExpectation], timeout: 1) } } diff --git a/CodeEditTests/Features/TerminalEmulator/Shell/ShellTests.swift b/CodeEditTests/Features/TerminalEmulator/Shell/ShellTests.swift deleted file mode 100644 index 034417417c..0000000000 --- a/CodeEditTests/Features/TerminalEmulator/Shell/ShellTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// ShellTests.swift -// CodeEditTests -// -// Created by Tommy Ludwig on 08.07.24. -// - -import XCTest -@testable import CodeEdit - -final class ShellTests: XCTestCase { - var process: Process! - var outputPipe: Pipe! - - override func setUp() { - super.setUp() - process = Process() - outputPipe = Pipe() - } - - override func tearDown() { - process = nil - outputPipe = nil - super.tearDown() - } - - func testExecuteCommandWithShellInitialization() throws { - let command = "echo $STATE" - let environmentVariables = ["STATE": "Testing"] - let shell: Shell = .bash - - XCTAssertNoThrow(try Shell.executeCommandWithShell( - process: process, - command: command, - environmentVariables: environmentVariables, - shell: shell, - outputPipe: outputPipe - )) - - XCTAssertEqual(process.executableURL, URL(fileURLWithPath: shell.url)) - XCTAssertEqual(process.environment, environmentVariables) - XCTAssertEqual(process.arguments, ["--login", "-c", command]) - XCTAssertNotNil(process.standardOutput) - XCTAssertNotNil(process.standardError) - - // Additional assertion to check output - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let outputString = try XCTUnwrap( - String(bytes: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - ) - XCTAssertTrue(outputString.contains("Testing")) - } - - func testExecuteCommandWithShellOutput() throws { - let command = "echo $STATE" - let environmentVariables = ["STATE": "Testing"] - let shell: Shell = .bash - - XCTAssertNoThrow(try Shell.executeCommandWithShell( - process: process, - command: command, - environmentVariables: environmentVariables, - shell: shell, - outputPipe: outputPipe - )) - - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let outputString = try XCTUnwrap( - String(bytes: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - ) - XCTAssertTrue(outputString.contains("Testing")) - } - - func testExecuteCommandWithExecutableOverrideAttempt() { - let command = "echo 'Hello, World!'" - let shell: Shell = .bash - - // Intentionally providing an invalid shell path to try trigger an error - process.executableURL = URL(fileURLWithPath: "/invalid/path") - - XCTAssertNoThrow(try Shell.executeCommandWithShell( - process: process, - command: command, - shell: shell, - outputPipe: outputPipe - )) - } -} diff --git a/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift b/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift index ba4383c5b3..2b160b829c 100644 --- a/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift +++ b/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift @@ -13,7 +13,12 @@ import XCTest final class ShellIntegrationTests: XCTestCase { func testBash() throws { var environment: [String] = [] - let args = try ShellIntegration.setUpIntegration(for: .bash, environment: &environment, useLogin: false) + let args = try ShellIntegration.setUpIntegration( + for: .bash, + environment: &environment, + useLogin: false, + interactive: true + ) XCTAssertTrue( environment.contains("\(ShellIntegration.Variables.ceInjection)=1"), "Does not contain injection flag" ) @@ -29,7 +34,12 @@ final class ShellIntegrationTests: XCTestCase { func testBashLogin() throws { var environment: [String] = [] - let args = try ShellIntegration.setUpIntegration(for: .bash, environment: &environment, useLogin: true) + let args = try ShellIntegration.setUpIntegration( + for: .bash, + environment: &environment, + useLogin: true, + interactive: true + ) XCTAssertTrue( environment.contains("\(ShellIntegration.Variables.ceInjection)=1"), "Does not contain injection flag" ) @@ -38,12 +48,17 @@ final class ShellIntegrationTests: XCTestCase { XCTAssertTrue( args.contains(where: { $0.hasSuffix("/codeedit_shell_integration.bash") }), "No setup file provided in args" ) - XCTAssertTrue(args.contains("-i"), "No interactive flag found") + XCTAssertTrue(args.contains("-il"), "No interactive login flag found") } func testZsh() throws { var environment: [String] = [] - let args = try ShellIntegration.setUpIntegration(for: .zsh, environment: &environment, useLogin: false) + let args = try ShellIntegration.setUpIntegration( + for: .zsh, + environment: &environment, + useLogin: false, + interactive: true + ) XCTAssertTrue(args.contains("-i"), "Interactive flag") XCTAssertTrue(!args.contains("-il"), "No Interactive/Login flag") @@ -72,7 +87,12 @@ final class ShellIntegrationTests: XCTestCase { func testZshLogin() throws { var environment: [String] = [] - let args = try ShellIntegration.setUpIntegration(for: .zsh, environment: &environment, useLogin: true) + let args = try ShellIntegration.setUpIntegration( + for: .zsh, + environment: &environment, + useLogin: true, + interactive: true + ) XCTAssertTrue(!args.contains("-i"), "No Interactive flag") XCTAssertTrue(args.contains("-il"), "Interactive/Login flag") diff --git a/CodeEditTests/Utils/waitForExpectation.swift b/CodeEditTests/Utils/waitForExpectation.swift new file mode 100644 index 0000000000..c282401aa8 --- /dev/null +++ b/CodeEditTests/Utils/waitForExpectation.swift @@ -0,0 +1,23 @@ +// +// waitForExpectation.swift +// CodeEditTests +// +// Created by Khan Winter on 7/15/25. +// + +func waitForExpectation( + timeout: ContinuousClock.Duration = .seconds(2.0), + _ expectation: () throws -> Bool, + onTimeout: () throws -> Void +) async rethrows { + let start = ContinuousClock.now + while .now - start < timeout { + if try expectation() { + return + } else { + await Task.yield() + } + } + + try onTimeout() +} diff --git a/CodeEditTests/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift new file mode 100644 index 0000000000..c6a5af75c3 --- /dev/null +++ b/CodeEditTests/Utils/withTempDir.swift @@ -0,0 +1,58 @@ +// +// withTempDir.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +import Foundation +import Testing + +func withTempDir(_ test: (URL) async throws -> Void) async throws { + guard let currentTest = Test.current else { + #expect(Bool(false)) + return + } + let tempDirURL = try createAndClearDir(file: currentTest.sourceLocation.fileID + currentTest.name) + do { + try await test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +func withTempDir(_ test: (URL) throws -> Void) throws { + guard let currentTest = Test.current else { + #expect(Bool(false)) + return + } + let tempDirURL = try createAndClearDir(file: currentTest.sourceLocation.fileID + currentTest.name) + do { + try test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +private func createAndClearDir(file: String) throws -> URL { + let file = file.components(separatedBy: CharacterSet(charactersIn: "/:?%*|\"<>")).joined() + let tempDirURL = FileManager.default.temporaryDirectory + .appending(path: "CodeEditTestDirectory" + file, directoryHint: .isDirectory) + + // If it exists, delete it before the test + try clearDir(tempDirURL) + + try FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true) + + return tempDirURL +} + +private func clearDir(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.absoluteURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: url) + } +} diff --git a/CodeEditUITests/Features/UtilityArea/TerminalUtility/TerminalUtilityUITests.swift b/CodeEditUITests/Features/UtilityArea/TerminalUtility/TerminalUtilityUITests.swift new file mode 100644 index 0000000000..bdaccfb5bc --- /dev/null +++ b/CodeEditUITests/Features/UtilityArea/TerminalUtility/TerminalUtilityUITests.swift @@ -0,0 +1,62 @@ +// +// TerminalUtilityUITests.swift +// CodeEditUITests +// +// Created by Khan Winter on 8/8/25. +// + +import XCTest + +final class TerminalUtilityUITests: XCTestCase { + var app: XCUIApplication! + var window: XCUIElement! + var utilityArea: XCUIElement! + var path: String! + + override func setUp() async throws { + // MainActor required for async compatibility which is required to make this method throwing + try await MainActor.run { + (app, path) = try App.launchWithTempDir() + + window = Query.getWindow(app) + XCTAssertTrue(window.exists, "Window not found") + window.toolbars.firstMatch.click() + + utilityArea = Query.Window.getUtilityArea(window) + XCTAssertTrue(utilityArea.exists, "Utility Area not found") + } + } + + func testTerminalsInputData() throws { + var terminal = utilityArea.textViews["Terminal Emulator"] + XCTAssertTrue(terminal.exists) + terminal.click() + terminal.typeText("echo hello world") + terminal.typeKey(.enter, modifierFlags: []) + + let value = try XCTUnwrap(terminal.value as? String) + XCTAssertEqual(value.components(separatedBy: "hello world").count - 1, 2) + } + + func testTerminalsKeepData() throws { + var terminal = utilityArea.textViews["Terminal Emulator"] + XCTAssertTrue(terminal.exists) + terminal.click() + terminal.typeText("echo hello world") + terminal.typeKey(.enter, modifierFlags: []) + + let terminals = utilityArea.descendants(matching: .any).matching(identifier: "terminalsList").element + XCTAssertTrue(terminals.exists) + terminals.click() + + let terminalRow = terminals.cells.firstMatch + XCTAssertTrue(terminalRow.exists) + terminalRow.click() + + terminal = utilityArea.textViews["Terminal Emulator"] + XCTAssertTrue(terminal.exists) + + let finalValue = try XCTUnwrap(terminal.value as? String) + XCTAssertEqual(finalValue.components(separatedBy: "hello world").count - 1, 2) + } +} diff --git a/CodeEditUITests/Query.swift b/CodeEditUITests/Query.swift index e958c89650..7e9387d09d 100644 --- a/CodeEditUITests/Query.swift +++ b/CodeEditUITests/Query.swift @@ -39,6 +39,10 @@ enum Query { static func getTabBar(_ window: XCUIElement) -> XCUIElement { return window.descendants(matching: .any).matching(identifier: "TabBar").element } + + static func getUtilityArea(_ window: XCUIElement) -> XCUIElement { + return window.descendants(matching: .any).matching(identifier: "UtilityArea").element + } } enum Navigator {