diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 800969d08..0d697ab9f 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -377,6 +377,7 @@ 6C1CC99B2B1E7CBC0002349B /* FindNavigatorIndexBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */; }; 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */; }; 6C23842F2C796B4C003FBDD4 /* GitChangedFileLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */; }; + 6C278CC72C93971F0066F6D9 /* LSPContentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */; }; 6C2C155829B4F49100EA60A5 /* SplitViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */; }; 6C2C155A29B4F4CC00EA60A5 /* Variadic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */; }; 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */; }; @@ -444,6 +445,8 @@ 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB4463F2B6DFF3A00539ED0 /* CodeEditSourceEditor */; }; 6CB52DC92AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */; }; 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 6CB94CFE2C9F1C9A00E8651C /* TextView+LSPRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */; }; + 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB94D022CA1205100E8651C /* AsyncAlgorithms */; }; 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */; }; 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */; }; 6CBE1CFB2B71DAA6003AC32E /* Loopable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */; }; @@ -1048,6 +1051,7 @@ 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorIndexBar.swift; sourceTree = ""; }; 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellIntegrationTests.swift; sourceTree = ""; }; 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitChangedFileLabel.swift; sourceTree = ""; }; + 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LSPContentCoordinator.swift; sourceTree = ""; }; 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewItem.swift; sourceTree = ""; }; 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variadic.swift; sourceTree = ""; }; 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewReader.swift; sourceTree = ""; }; @@ -1103,6 +1107,7 @@ 6CA1AE942B46950000378EAB /* EditorInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorInstance.swift; sourceTree = ""; }; 6CABB1A029C5593800340467 /* SearchPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchPanelView.swift; sourceTree = ""; }; 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+FileManagement.swift"; sourceTree = ""; }; + 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextView+LSPRange.swift"; sourceTree = ""; }; 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlImproved.swift; sourceTree = ""; }; 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Caption3.swift"; sourceTree = ""; }; 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Loopable.swift; sourceTree = ""; }; @@ -1310,6 +1315,7 @@ 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, + 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1552,7 +1558,6 @@ 30B087FB2C0D53080063A882 /* LSP */ = { isa = PBXGroup; children = ( - 6CD26C822C8F8A5F00ADBA38 /* Extensions */, 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, 6CD26C742C8EA79100ADBA38 /* Service */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, @@ -2442,9 +2447,11 @@ 5831E3C62933E7E600D5A6D2 /* Color */, 669A504F2C380BFD00304CD8 /* Collection */, 5831E3C82933E80500D5A6D2 /* Date */, + 6CB94D002C9F1CF900E8651C /* LanguageIdentifier */, 6C82D6C429C0129E00495C54 /* NSApplication */, 5831E3D02934036D00D5A6D2 /* NSTableView */, 77A01E922BCA9C0400F0EA38 /* NSWindow */, + 6CB94CFF2C9F1CB600E8651C /* TextView */, 77EF6C042C57DE4B00984B69 /* URL */, 58D01C8B293167DC00C5B6B4 /* String */, 5831E3CB2933E89A00D5A6D2 /* SwiftTerm */, @@ -2968,6 +2975,22 @@ path = WindowCommands; sourceTree = ""; }; + 6CB94CFF2C9F1CB600E8651C /* TextView */ = { + isa = PBXGroup; + children = ( + 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */, + ); + path = TextView; + sourceTree = ""; + }; + 6CB94D002C9F1CF900E8651C /* LanguageIdentifier */ = { + isa = PBXGroup; + children = ( + 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */, + ); + path = LanguageIdentifier; + sourceTree = ""; + }; 6CBD1BC42978DE3E006639D5 /* Text */ = { isa = PBXGroup; children = ( @@ -3006,6 +3029,7 @@ 6CD26C6D2C8EA1E600ADBA38 /* LanguageServerFileMap.swift */, 6CD26C782C8EA8A500ADBA38 /* LSPCache.swift */, 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */, + 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */, 30B0881E2C12626B0063A882 /* Capabilities */, ); path = LanguageServer; @@ -3030,14 +3054,6 @@ path = URL; sourceTree = ""; }; - 6CD26C822C8F8A5F00ADBA38 /* Extensions */ = { - isa = PBXGroup; - children = ( - 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */, - ); - path = Extensions; - sourceTree = ""; - }; 6CD26C882C8F91B600ADBA38 /* LSP */ = { isa = PBXGroup; children = ( @@ -3678,6 +3694,7 @@ 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, + 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3775,6 +3792,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4357,6 +4375,7 @@ 5878DA82291863F900DD95A3 /* AcknowledgementsView.swift in Sources */, 587B9E8529301D8F00AC7927 /* GitHubReview.swift in Sources */, 58D01C9A293167DC00C5B6B4 /* CodeEditKeychain.swift in Sources */, + 6CB94CFE2C9F1C9A00E8651C /* TextView+LSPRange.swift in Sources */, B6966A2E2C3056AD00259C2D /* SourceControlCommands.swift in Sources */, B62AEDAA2A1FCBE5009A9F52 /* AreaTabBar.swift in Sources */, 20D839AB280DEB2900B27357 /* NoSelectionInspectorView.swift in Sources */, @@ -4399,6 +4418,7 @@ 6C1CC9982B1E770B0002349B /* AsyncFileIterator.swift in Sources */, 587B9E9029301D8F00AC7927 /* BitBucketTokenRouter.swift in Sources */, B6C6A42E29771A8D00A3D28F /* EditorTabButtonStyle.swift in Sources */, + 6C278CC72C93971F0066F6D9 /* LSPContentCoordinator.swift in Sources */, 58822525292C280D00E83CDE /* StatusBarMenuStyle.swift in Sources */, 6C147C4229A328C10089B630 /* Editor.swift in Sources */, B6C4F2A32B3CA74800B2B140 /* CommitDetailsView.swift in Sources */, @@ -5659,12 +5679,20 @@ minimumVersion = 1.2.0; }; }; + 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = exactVersion; + version = 1.0.1; + }; + }; 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.8.0; + minimumVersion = 0.8.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -5752,6 +5780,11 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 6CB94D022CA1205100E8651C /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; 6CC17B4E2C432AE000834E2C /* 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 1a87a7426..a723cdba1 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b6e0c892d567c4fb43e4135487752085a69cf403b95ee27c28e9d213dd3bbf5c", + "originHash" : "5c4a5d433333474763817b9804d7f1856ab3b416ed87b190a2bd6e86c0c9834c", "pins" : [ { "identity" : "anycodable", @@ -13,7 +13,7 @@ { "identity" : "codeeditkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditKit", + "location" : "https://github.com/CodeEditApp/CodeEditKit.git", "state" : { "revision" : "ad28213a968586abb0cb21a8a56a3587227895f1", "version" : "0.1.2" @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", "state" : { - "revision" : "7d08e741c412b6fd30d5eea8bb6c0580e89553cf", - "version" : "0.8.0" + "revision" : "033b68d3e3e845984fbc3d405720d5cc6ce61f71", + "version" : "0.8.1" } }, { @@ -181,6 +181,15 @@ "version" : "2.3.0" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index b5b70f4d6..e3afda72c 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -47,6 +47,12 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// See ``CodeEditSourceEditor/CombineCoordinator``. @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() + lazy var languageServerCoordinator: LSPContentCoordinator = { + let coordinator = LSPContentCoordinator() + coordinator.uri = self.languageServerURI + return coordinator + }() + /// Used to override detected languages. @Published var language: CodeLanguage? diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index e6cccee5c..1a0ae19c9 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -56,7 +56,10 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) - self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator] + self.textViewCoordinators = textViewCoordinators + [ + codeFile.contentCoordinator, + codeFile.languageServerCoordinator + ] self.isEditable = isEditable if let openOptions = codeFile.openOptions { diff --git a/CodeEdit/Features/Extensions/ExtensionDiscovery.swift b/CodeEdit/Features/Extensions/ExtensionDiscovery.swift index 36c28e96d..8418985c7 100644 --- a/CodeEdit/Features/Extensions/ExtensionDiscovery.swift +++ b/CodeEdit/Features/Extensions/ExtensionDiscovery.swift @@ -77,7 +77,6 @@ final class ExtensionDiscovery: ObservableObject { Task { [weak self] in for await availability in AppExtensionIdentity.availabilityUpdates { guard !Task.isCancelled && self != nil else { return } - print(availability) do { if availability.disabledCount > 0 { print("Found \(availability.disabledCount) disabled extensions, trying to activate...") diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index e78bf7e5b..dcf12fa83 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -9,42 +9,6 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - // swiftlint:disable line_length - /// Determines the type of document sync the server supports. - /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc - fileprivate func resolveDocumentSyncKind() -> TextDocumentSyncKind { - // swiftlint:enable line_length - var syncKind: TextDocumentSyncKind = .none - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - syncKind = options.change ?? .none - case .optionB(let kind): - syncKind = kind - default: - syncKind = .none - } - return syncKind - } - - /// Determines whether or not the server supports document tracking. - fileprivate func resolveOpenCloseSupport() -> Bool { - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - return options.openClose ?? false - case .optionB: - return true - default: - return true - } - } - - // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` - fileprivate struct DocumentContent { - let uri: String - let language: LanguageIdentifier - let content: String - } - /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. /// - Throws: Throws errors produced by the language server connection. @@ -61,26 +25,16 @@ extension LanguageServer { uri: content.uri, languageId: content.language, version: 0, - text: content.content + text: content.string ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) + await updateIsolatedTextCoordinator(for: document) } catch { logger.warning("addDocument: Error \(error)") throw error } } - /// Helper function for grabbing a document's content from the main actor. - @MainActor - private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { - guard let uri = document.languageServerURI, - let language = document.getLanguage().lspLanguage, - let content = document.content?.string else { - return nil - } - return DocumentContent(uri: uri, language: language, content: content) - } - /// Stops tracking a file and notifies the language server. /// - Parameter uri: The URI of the document to close. /// - Throws: Throws errors produced by the language server connection. @@ -97,37 +51,50 @@ extension LanguageServer { } } + /// Represents a single document edit event. + public struct DocumentChange: Sendable { + let range: LSPRange + let string: String + + init(replacingContentsIn range: LSPRange, with string: String) { + self.range = range + self.string = string + } + } + /// Updates the document with the specified URI with new text and increments its version. + /// + /// This API accepts an array of changes to allow for grouping change notifications. + /// This is advantageous for full document changes as we reduce the number of times we send the entire document. + /// It also lowers some communication overhead when sending lots of changes very quickly due to sending them all in + /// one request. + /// /// - Parameters: /// - uri: The URI of the document to update. - /// - range: The range being replaced. - /// - string: The string being inserted into the replacement range. + /// - changes: An array of accumulated changes. It's suggested to throttle change notifications and send them + /// in groups. /// - Throws: Throws errors produced by the language server connection. - func documentChanged( - uri: String, - replacedContentIn range: LSPRange, - with string: String - ) async throws { + func documentChanged(uri: String, changes: [DocumentChange]) async throws { do { logger.debug("Document updated, \(uri, privacy: .private)") switch resolveDocumentSyncKind() { case .full: - guard let file = openFiles.document(for: uri) else { return } - let content = await MainActor.run { - let storage = file.content - return storage?.string + guard let document = openFiles.document(for: uri), + let content = await getIsolatedDocumentContent(document) else { + return } - guard let content else { return } - let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content) + let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content.string) try await lspInstance.textDocumentDidChange( DidChangeTextDocumentParams(uri: uri, version: 0, contentChange: changeEvent) ) case .incremental: let fileVersion = openFiles.incrementVersion(for: uri) - // rangeLength is depreciated in the LSP spec. - let changeEvent = TextDocumentContentChangeEvent(range: range, rangeLength: nil, text: string) + let changeEvents = changes.map { + // rangeLength is depreciated in the LSP spec. + TextDocumentContentChangeEvent(range: $0.range, rangeLength: nil, text: $0.string) + } try await lspInstance.textDocumentDidChange( - DidChangeTextDocumentParams(uri: uri, version: fileVersion, contentChange: changeEvent) + DidChangeTextDocumentParams(uri: uri, version: fileVersion, contentChanges: changeEvents) ) case .none: return @@ -137,4 +104,59 @@ extension LanguageServer { throw error } } + + // MARK: File Private Helpers + + /// Helper function for grabbing a document's content from the main actor. + @MainActor + private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + guard let uri = document.languageServerURI, + let language = document.getLanguage().lspLanguage, + let content = document.content?.string else { + return nil + } + return DocumentContent(uri: uri, language: language, string: content) + } + + /// Updates the actor-isolated document's text coordinator to map to this server. + @MainActor + fileprivate func updateIsolatedTextCoordinator(for document: CodeFileDocument) { + document.languageServerCoordinator.languageServer = self + } + + // swiftlint:disable line_length + /// Determines the type of document sync the server supports. + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc + fileprivate func resolveDocumentSyncKind() -> TextDocumentSyncKind { + // swiftlint:enable line_length + var syncKind: TextDocumentSyncKind = .none + switch serverCapabilities.textDocumentSync { + case .optionA(let options): // interface TextDocumentSyncOptions + syncKind = options.change ?? .none + case .optionB(let kind): // interface TextDocumentSyncKind + syncKind = kind + default: + syncKind = .none + } + return syncKind + } + + /// Determines whether or not the server supports document tracking. + fileprivate func resolveOpenCloseSupport() -> Bool { + switch serverCapabilities.textDocumentSync { + case .optionA(let options): // interface TextDocumentSyncOptions + return options.openClose ?? false + case .optionB: // interface TextDocumentSyncKind + return true + default: + return true + } + } + + // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` + fileprivate struct DocumentContent { + let uri: String + let language: LanguageIdentifier + let string: String + } } diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift new file mode 100644 index 000000000..9c405f64e --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift @@ -0,0 +1,92 @@ +// +// LSPContentCoordinator.swift +// CodeEdit +// +// Created by Khan Winter on 9/12/24. +// + +import AppKit +import AsyncAlgorithms +import CodeEditSourceEditor +import CodeEditTextView +import LanguageServerProtocol + +/// This content coordinator forwards content notifications from the editor's text storage to a language service. +/// +/// This is a text view coordinator so that it can be installed on an open editor. It is kept as a property on +/// ``CodeFileDocument`` since the language server does all it's document management using instances of that type. +/// +/// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class +/// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then +/// chunked into 250ms timed groups before being sent to the ``LanguageServer``. +class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { + // Required to avoid a large_tuple lint error + private struct SequenceElement: Sendable { + let uri: String + let range: LSPRange + let string: String + } + + private var editedRange: LSPRange? + private var stream: AsyncStream? + private var sequenceContinuation: AsyncStream.Continuation? + private var task: Task? + + weak var languageServer: LanguageServer? + var uri: String? + + init() { + self.stream = AsyncStream { continuation in + self.sequenceContinuation = continuation + } + } + + func setUpUpdatesTask() { + task?.cancel() + guard let stream else { return } + task = Task.detached { [weak self] in + // Send edit events every 250ms + for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) { + guard !Task.isCancelled, self != nil else { return } + guard !events.isEmpty, let uri = events.first?.uri else { continue } + // Errors thrown here are already logged, not much else to do right now. + try? await self?.languageServer?.documentChanged( + uri: uri, + changes: events.map { + LanguageServer.DocumentChange(replacingContentsIn: $0.range, with: $0.string) + } + ) + } + } + } + + func prepareCoordinator(controller: TextViewController) { + setUpUpdatesTask() + } + + /// We grab the lsp range before the content (and layout) is changed so we get correct line/col info for the + /// language server range. + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { + self.editedRange = textView.lspRangeFrom(nsRange: range) + } + + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + guard let uri, + let lspRange = editedRange else { + return + } + self.editedRange = nil + self.sequenceContinuation?.yield(SequenceElement(uri: uri, range: lspRange, string: string)) + } + + func destroy() { + task?.cancel() + task = nil + sequenceContinuation?.finish() + sequenceContinuation = nil + } + + deinit { + destroy() + } +} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 53d9298a6..beeec7dd6 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -38,7 +38,9 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { if !item.isFolder && shouldSendSelectionUpdate { DispatchQueue.main.async { [weak self] in self?.shouldSendSelectionUpdate = false - self?.workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) + if self?.workspace?.editorManager?.activeEditor.selectedTab?.file != item { + self?.workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) + } self?.shouldSendSelectionUpdate = true } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index df4cf5228..a47e85176 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -6,108 +6,116 @@ // import SwiftUI +import Combine import CodeEditSourceEditor struct StatusBarCursorPositionLabel: View { - @Environment(\.controlActiveState) - private var controlActive - @Environment(\.modifierKeys) - private var modifierKeys - - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel @EnvironmentObject private var editorManager: EditorManager @State private var tab: EditorInstance? - @State private var cursorPositions: [CursorPosition]? /// Updates the source of cursor position notifications. func updateSource() { tab = editorManager.activeEditor.selectedTab } - /// Finds the lines contained by a range in the currently selected document. - /// - Parameter range: The range to query. - /// - Returns: The number of lines in the range. - func getLines(_ range: NSRange) -> Int { - return tab?.rangeTranslator?.linesInRange(range) ?? 0 + var body: some View { + Group { + if let currentTab = tab { + LineLabel(editorInstance: currentTab) + } else { + Text("").accessibilityLabel("No Selection") + } + } + .fixedSize() + .accessibilityIdentifier("CursorPositionLabel") + .accessibilityAddTraits(.updatesFrequently) + .onHover { isHovering($0) } + .onAppear { + updateSource() + } + .onReceive(editorManager.tabBarTabIdSubject) { _ in + updateSource() + } } - /// Create a label string for cursor positions. - /// - Parameter cursorPositions: The cursor positions to create the label for. - /// - Returns: A string describing the user's location in a document. - func getLabel(_ cursorPositions: [CursorPosition]) -> String { - if cursorPositions.isEmpty { - return "" + struct LineLabel: View { + @Environment(\.modifierKeys) + private var modifierKeys + @Environment(\.controlActiveState) + private var controlActive + + @EnvironmentObject private var statusBarViewModel: StatusBarViewModel + + let editorInstance: EditorInstance + + @State private var cursorPositions: [CursorPosition] = [] + + init(editorInstance: EditorInstance) { + self.editorInstance = editorInstance } - // More than one selection, display the number of selections. - if cursorPositions.count > 1 { - return "\(cursorPositions.count) selected ranges" + var body: some View { + Text(getLabel()) + .font(statusBarViewModel.statusBarFont) + .foregroundColor(foregroundColor) + .lineLimit(1) + .onReceive(editorInstance.cursorPositions) { newValue in + self.cursorPositions = newValue + } } - // If the selection is more than just a cursor, return the length. - if cursorPositions[0].range.length > 0 { - // When the option key is pressed display the character range. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" + private var foregroundColor: Color { + if controlActive == .inactive { + Color(nsColor: .disabledControlTextColor) + } else { + Color(nsColor: .secondaryLabelColor) } + } - let lineCount = getLines(cursorPositions[0].range) + /// Finds the lines contained by a range in the currently selected document. + /// - 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 + } - if lineCount > 1 { - return "\(lineCount) lines" + /// Create a label string for cursor positions. + /// - Returns: A string describing the user's location in a document. + func getLabel() -> String { + if cursorPositions.isEmpty { + return "" } - return "\(cursorPositions[0].range.length) characters" - } + // More than one selection, display the number of selections. + if cursorPositions.count > 1 { + return "\(cursorPositions.count) selected ranges" + } - // When the option key is pressed display the character offset. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: 0" - } + // If the selection is more than just a cursor, return the length. + if cursorPositions[0].range.length > 0 { + // When the option key is pressed display the character range. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" + } - // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" - } + let lineCount = getLines(cursorPositions[0].range) - var body: some View { - Group { - if let currentTab = tab { - Group { - if let cursorPositions = cursorPositions { - Text(getLabel(cursorPositions)) - } else { - EmptyView() - } - } - .onReceive(currentTab.cursorPositions) { val in - cursorPositions = val + if lineCount > 1 { + return "\(lineCount) lines" } - } else { - EmptyView() + + return "\(cursorPositions[0].range.length) characters" + } + + // When the option key is pressed display the character offset. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: 0" } - } - .font(statusBarViewModel.statusBarFont) - .foregroundColor(foregroundColor) - .fixedSize() - .lineLimit(1) - .onHover { isHovering($0) } - .onAppear { - updateSource() - } - .onReceive(editorManager.activeEditor.objectWillChange) { _ in - updateSource() - } - .onChange(of: editorManager.activeEditor) { _ in - updateSource() - } - .onChange(of: editorManager.activeEditor.selectedTab) { _ in - updateSource() - } - } - private var foregroundColor: Color { - controlActive == .inactive ? Color(nsColor: .disabledControlTextColor) : Color(nsColor: .secondaryLabelColor) + // When there's a single cursor, display the line and column. + return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" + } } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift index a40f4d953..cb73012d8 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift @@ -38,9 +38,7 @@ struct StatusBarView: View { // StatusBarDivider() Spacer() StatusBarFileInfoView() - HStack(alignment: .center, spacing: 10) { - StatusBarCursorPositionLabel() - } + StatusBarCursorPositionLabel() StatusBarDivider() StatusBarToggleUtilityAreaButton() } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift index bce73acc3..9d6aba9d6 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift @@ -115,7 +115,7 @@ struct UtilityAreaTerminalView: View { } } ) - .frame(height: constrainedHeight - 1) + .frame(height: max(0, constrainedHeight - 1)) .id(selectedTerminal.id) } } diff --git a/CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift b/CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift similarity index 100% rename from CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift rename to CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift b/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift new file mode 100644 index 000000000..4d7d3858c --- /dev/null +++ b/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift @@ -0,0 +1,23 @@ +// +// TextView+LSPRange.swift +// CodeEdit +// +// Created by Khan Winter on 9/21/24. +// + +import AppKit +import CodeEditTextView +import LanguageServerProtocol + +extension TextView { + func lspRangeFrom(nsRange: NSRange) -> LSPRange? { + guard let startLine = layoutManager.textLineForOffset(nsRange.location), + let endLine = layoutManager.textLineForOffset(nsRange.max) else { + return nil + } + return LSPRange( + start: Position(line: startLine.index, character: nsRange.location - startLine.range.location), + end: Position(line: endLine.index, character: nsRange.max - endLine.range.location) + ) + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index 4b460405f..5f9aad9aa 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -6,6 +6,8 @@ // import XCTest +import CodeEditTextView +import CodeEditSourceEditor import LanguageClient import LanguageServerProtocol @@ -18,10 +20,15 @@ final class LanguageServerDocumentTests: XCTestCase { var tempTestDir: URL! override func setUp() { + continueAfterFailure = false do { let tempDir = FileManager.default.temporaryDirectory.appending( path: "codeedit-lsp-tests" ) + // Clean up first. + if FileManager.default.fileExists(atPath: tempDir.absoluteURL.path()) { + try FileManager.default.removeItem(at: tempDir) + } try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) tempTestDir = tempDir } catch { @@ -73,8 +80,19 @@ final class LanguageServerDocumentTests: XCTestCase { return (workspace, fileManager) } + func waitForClientEventCount(_ count: Int, connection: BufferingServerConnection, description: String) async { + let expectation = expectation(description: description) + Task.detached { + while connection.clientNotifications.count + connection.clientRequests.count < count { + try await Task.sleep(for: .milliseconds(10)) + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 2) + } + @MainActor - func testOpenFileInWorkspaceNotifiesLSP() async throws { + func testOpenCloseFileNotifications() async throws { // Set up test server let (connection, server) = try await makeTestServer() @@ -103,28 +121,12 @@ final class LanguageServerDocumentTests: XCTestCase { // This should trigger a documentDidOpen event CodeEditDocumentController.shared.addDocument(codeFile) - let eventCountExpectation = expectation(description: "Pre-close event count") - // Wait off the main actor until we've received all the events - Task.detached { - while connection.clientNotifications.count + connection.clientRequests.count < 3 { - try await Task.sleep(for: .milliseconds(10)) - } - eventCountExpectation.fulfill() - } - - await fulfillment(of: [eventCountExpectation], timeout: 2) + await waitForClientEventCount(3, connection: connection, description: "Pre-close event count") // This should then trigger a documentDidClose event codeFile.close() - let eventCloseExpectation = expectation(description: "Post-close event count") - Task.detached { - while connection.clientNotifications.count + connection.clientRequests.count < 4 { - try await Task.sleep(for: .milliseconds(10)) - } - eventCloseExpectation.fulfill() - } - await fulfillment(of: [eventCloseExpectation], timeout: 2) + await waitForClientEventCount(4, connection: connection, description: "Post-close event count") XCTAssertEqual( connection.clientRequests.map { $0.method }, @@ -142,4 +144,153 @@ final class LanguageServerDocumentTests: XCTestCase { ] ) } + + /// Assert the changed contents received by the buffered connection + func assertExpectedContentChanges(connection: BufferingServerConnection, changes: [String]) { + var foundChangeContents: [String] = [] + + for notification in connection.clientNotifications { + switch notification { + case let .textDocumentDidChange(params): + foundChangeContents.append(contentsOf: params.contentChanges.map { event in + event.text + }) + default: + continue + } + } + + XCTAssertEqual(changes, foundChangeContents) + } + + @MainActor + func testDocumentEditNotificationsFullChanges() async throws { + // Set up a workspace in the temp directory + let (_, fileManager) = try makeTestWorkspace() + + // Make our example file + try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { + XCTFail("No File") + return + } + + // Need to test both definitions for server capabilities + let syncOptions: [TwoTypeOption] = [ + .optionA(.init(change: .full)), + .optionB(.full) + ] + + for option in syncOptions { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: "public.swift-source" + ) + + // Set up full content changes + server.serverCapabilities = ServerCapabilities() + server.serverCapabilities.textDocumentSync = option + server.openFiles.addDocument(codeFile) + codeFile.languageServerCoordinator.languageServer = server + codeFile.languageServerCoordinator.setUpUpdatesTask() + codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) + + let textView = TextView(string: "") + textView.setTextStorage(codeFile.content!) + textView.delegate = codeFile.languageServerCoordinator + 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") + + await waitForClientEventCount(3, connection: connection, description: "Edited notification count") + + // Make sure our text view is intact + XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) + XCTAssertEqual( + [ + ClientNotification.Method.initialized, + ClientNotification.Method.textDocumentDidChange + ], + connection.clientNotifications.map { $0.method } + ) + + // Expect only one change due to throttling. + assertExpectedContentChanges( + connection: connection, + changes: [#"func testFunction() -> String { "Hello World" }"#] + ) + } + } + + @MainActor + func testDocumentEditNotificationsIncrementalChanges() async throws { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Set up a workspace in the temp directory + let (_, fileManager) = try makeTestWorkspace() + + // Make our example file + try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { + XCTFail("No File") + return + } + + let syncOptions: [TwoTypeOption] = [ + .optionA(.init(change: .incremental)), + .optionB(.incremental) + ] + + for option in syncOptions { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: "public.swift-source" + ) + + // Set up full content changes + server.serverCapabilities = ServerCapabilities() + server.serverCapabilities.textDocumentSync = option + server.openFiles.addDocument(codeFile) + codeFile.languageServerCoordinator.languageServer = server + codeFile.languageServerCoordinator.setUpUpdatesTask() + codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) + + let textView = TextView(string: "") + textView.setTextStorage(codeFile.content!) + textView.delegate = codeFile.languageServerCoordinator + 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") + + // Throttling means we should receive one edited notification + init notification + init request + await waitForClientEventCount(3, connection: connection, description: "Edited notification count") + + // Make sure our text view is intact + XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) + XCTAssertEqual( + [ + ClientNotification.Method.initialized, + ClientNotification.Method.textDocumentDidChange + ], + connection.clientNotifications.map { $0.method } + ) + + // Expect three content changes. + assertExpectedContentChanges( + connection: connection, + changes: ["Worlld", "", "World"] + ) + } + } }