diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index bf5c71f97..14af561e4 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -64,6 +64,8 @@ 3000516A2BBD3A8200A98562 /* ServiceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300051692BBD3A8200A98562 /* ServiceType.swift */; }; 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */; }; 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */; }; + 3046374E2CB15FA900180667 /* AutoCompleteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */; }; + 3048523D2D182DA6000CD5CF /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */; }; 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */; }; 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */; }; @@ -457,8 +459,7 @@ 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 */; }; - 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; - 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; + 6CC17B4F2C432AE000834E2C /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 6CC17B512C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */; }; 6CC17B532C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B522C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift */; }; 6CC17B5B2C44258700834E2C /* WindowControllerPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B5A2C44258700834E2C /* WindowControllerPropertyWrapper.swift */; }; @@ -474,7 +475,7 @@ 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */; }; 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; - 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; + 6CD3CA552C8B508200D83DCD /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; @@ -761,6 +762,7 @@ 300051692BBD3A8200A98562 /* ServiceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceType.swift; sourceTree = ""; }; 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceWrapper.swift; sourceTree = ""; }; 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorAreaViewModel.swift; sourceTree = ""; }; + 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteCoordinator.swift; sourceTree = ""; }; 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueTable.swift; sourceTree = ""; }; @@ -1321,6 +1323,10 @@ EC0870F62A455F6400EB8692 /* ProjectNavigatorViewController+NSMenuDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+NSMenuDelegate.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 302EFC1F2CC3C034004A74DF /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 2BE487E928245162003F3F64 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -1333,15 +1339,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3048523D2D182DA6000CD5CF /* CodeEditSourceEditor in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, - 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, - 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, + 6CD3CA552C8B508200D83DCD /* (null) in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, - 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, + 6CC17B4F2C432AE000834E2C /* (null) in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */, 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, @@ -1600,6 +1606,7 @@ 6C3B4CD22D0E2C5400C6759E /* Editor */, 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, 6CD26C742C8EA79100ADBA38 /* Service */, + 302EFC1F2CC3C034004A74DF /* Views */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, ); path = LSP; @@ -2868,6 +2875,7 @@ 287776EB27E350BA00D46668 /* TabBar */, B67660642AA970ED00CD56B0 /* Models */, B67660632AA970E300CD56B0 /* Views */, + 3046374D2CB15F9200180667 /* AutoCompleteCoordinator.swift */, ); path = Editor; sourceTree = ""; @@ -3747,6 +3755,9 @@ 6C7B1C762A1D57CE005CBBFC /* PBXTargetDependency */, 2BE487F328245162003F3F64 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 302EFC1F2CC3C034004A74DF /* Views */, + ); name = CodeEdit; packageProductDependencies = ( 2816F593280CF50500DD548B /* CodeEditSymbols */, @@ -3760,11 +3771,9 @@ 6C0617D52BDB4432008C9C42 /* LogStream */, 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */, 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */, - 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */, 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, - 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, 6C05CF9D2CDE8699006AAECD /* CodeEditSourceEditor */, @@ -3865,7 +3874,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6C05CF9C2CDE8699006AAECD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 302EFBFB2CC3284D004A74DF /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4247,6 +4256,7 @@ 58A5DFA229339F6400D1BD5D /* KeybindingManager.swift in Sources */, B62AEDB32A1FD95B009A9F52 /* UtilityAreaTerminalView.swift in Sources */, 661EF7BD2BEE215300C3E577 /* LoadingFileView.swift in Sources */, + 3046374E2CB15FA900180667 /* AutoCompleteCoordinator.swift in Sources */, 58AFAA2E2933C69E00482B53 /* EditorTabRepresentable.swift in Sources */, 6C4104E6297C884F00F472BA /* AboutDetailView.swift in Sources */, 6C6BD6F129CD13FA00235D17 /* ExtensionDiscovery.swift in Sources */, @@ -5654,6 +5664,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 302EFBFB2CC3284D004A74DF /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CodeEditSourceEditor; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -5890,14 +5907,6 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - productName = CodeEditSourceEditor; - }; - 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - productName = CodeEditSourceEditor; - }; 6CE21E862C650D2C0031B056 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; productName = SwiftTerm; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b65c217af..3a0778c34 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ac57a6899925c3e4ac6d43aed791c845c6fc24a4441b6a10297a207d951b7836", + "originHash" : "bb72acfad31b288599b6721256b508d8209ba1bc1d7ab0fff6a358d49a1deae0", "pins" : [ { "identity" : "anycodable", @@ -13,7 +13,7 @@ { "identity" : "codeeditkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditKit.git", + "location" : "https://github.com/CodeEditApp/CodeEditKit", "state" : { "revision" : "ad28213a968586abb0cb21a8a56a3587227895f1", "version" : "0.1.2" @@ -28,15 +28,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "b0688fa59fb8060840fb013afb4d6e6a96000f14", - "version" : "0.9.1" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -123,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/LanguageServerProtocol", "state" : { - "revision" : "ac76fccf0e981c8e30c5ee4de1b15adc1decd697", - "version" : "0.13.2" + "revision" : "d51412945ae88ffcab65ec339ca89aed9c9f0b8a", + "version" : "0.13.3" } }, { @@ -150,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattmassicotte/Queue", "state" : { - "revision" : "8d6f936097888f97011610ced40313655dc5948d", - "version" : "0.1.4" + "revision" : "6adf359a705e3252742905b413bb8f56401043ca", + "version" : "0.2.0" } }, { @@ -195,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -222,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "5a65f4074975f811da666dfe31a19850950b1ea4", - "version" : "0.56.2" + "revision" : "87454f5c9ff4d644086aec2a0df1ffba678e7f3c", + "version" : "0.57.1" } }, { diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 4e1638b9d..eb8e1da08 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -10,7 +10,6 @@ import Foundation import SwiftUI import UniformTypeIdentifiers import CodeEditSourceEditor -import CodeEditTextView import CodeEditLanguages import Combine import OSLog diff --git a/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift new file mode 100644 index 000000000..8d1a3a43f --- /dev/null +++ b/CodeEdit/Features/Editor/AutoCompleteCoordinator.swift @@ -0,0 +1,263 @@ +// +// AutoCompleteCoordinator.swift +// CodeEdit +// +// Created by Abe Malla on 9/20/24. +// + +import AppKit +import SwiftTreeSitter +import CodeEditTextView +import CodeEditSourceEditor +import LanguageServerProtocol + +class AutoCompleteCoordinator: TextViewCoordinator { + /// A reference to the `TextViewController`, to be able to make edits + private weak var textViewController: TextViewController? + /// A reference to the file we are working with, to be able to query file information + private unowned var file: CEWorkspaceFile + /// The event monitor that looks for the keyboard shortcut to bring up the autocomplete menu + private var localEventMonitor: Any? + /// The `SuggestionController` lets us display the autocomplete items + private var suggestionController: SuggestionController? + /// The current TreeSitter node that the main cursor is at + private var currentNode: SwiftTreeSitter.Node? + /// The current filter text based on partial token input + private var currentFilterText: String = "" + /// Stores the unfiltered completion items + private var completionItems: [CompletionItem] = [] + + init(_ file: CEWorkspaceFile) { + self.file = file + } + + func prepareCoordinator(controller: TextViewController) { + suggestionController = SuggestionController() + suggestionController?.delegate = self + suggestionController?.close() + self.textViewController = controller + + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // `ctrl + space` keyboard shortcut listener for the item box to show + if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == " " { + Task { + await self.showAutocompleteWindow() + } + return nil + } + return event + } + } + + /// Will query the language server for autocomplete suggestions and then display the window. + @MainActor + func showAutocompleteWindow() { + guard let cursorPos = textViewController?.cursorPositions.first, + let textView = textViewController?.textView, + let window = NSApplication.shared.keyWindow, + let suggestionController = suggestionController + else { + return + } + + var tokenSubstringCount = 0 + currentFilterText = "" + do { + if let token = try textViewController?.treeSitterClient?.nodesAt(range: cursorPos.range).first { + if tokenIsActionable(token.node) { + currentNode = token.node + + // Get the string from the start of the token to the location of the cursor + if cursorPos.range.location > token.node.range.location { + let selectedRange = NSRange( + location: token.node.range.location, + length: cursorPos.range.location - token.node.range.location + ) + if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + tokenSubstringCount = tokenSubstring.count + } + } + } + } + } catch { + print("Error getting TreeSitter node: \(error)") + } + + Task { + var textPosition = Position(line: cursorPos.line - 1, character: cursorPos.column - 1) + // If we are asking for completions in the middle of a token, then + // query the language server for completion items at the start of the token + if currentNode != nil { + textPosition = Position( + line: cursorPos.line - 1, + character: cursorPos.column - tokenSubstringCount - 1 + ) + } + completionItems = await fetchCompletions(position: textPosition) + suggestionController.items = filterCompletionItems(completionItems) + + let cursorRect = textView.firstRect(forCharacterRange: cursorPos.range, actualRange: nil) + suggestionController.constrainWindowToScreenEdges( + cursorRect: cursorRect, + // TODO: CALCULATE PADDING BASED ON FONT SIZE, THIS IS JUST TEMP + horizontalOffset: 13 + 16.5 + CGFloat(tokenSubstringCount) * 7.4 + ) + suggestionController.showWindow(attachedTo: window) + } + } + + private func fetchCompletions(position: Position) async -> [CompletionItem] { + let workspace = await file.fileDocument?.findWorkspace() + guard let workspacePath = workspace?.fileURL?.absoluteURL.path() else { return [] } + guard let language = await file.fileDocument?.getLanguage().lspLanguage else { return [] } + + @Service var lspService: LSPService + guard let client = await lspService.languageClient( + for: language, + workspacePath: workspacePath + ) else { + return [] + } + + do { + let completions = try await client.requestCompletion( + for: file.url.absoluteURL.path(), + position: position + ) + + // Extract the completion items list + switch completions { + case .optionA(let completionItems): + return completionItems + case .optionB(let completionList): + return completionList.items + case .none: + return [] + } + } catch { + return [] + } + } + + /// Filters completion items based on the current partial token input + private func filterCompletionItems(_ items: [CompletionItem]) -> [CompletionItem] { + guard !currentFilterText.isEmpty else { + return items + } + + return items.filter { item in + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + let label = item.label.lowercased() + let filterText = currentFilterText.lowercased() + if insertText.lowercased().hasPrefix(filterText) { + return true + } + if label.hasPrefix(filterText) { + return true + } + return false + } + } + + /// Determines if a TreeSitter node is a type where we can build featues off of. This helps filter out + /// nodes that represent blank spaces or other information that is not useful. + private func tokenIsActionable(_ node: SwiftTreeSitter.Node) -> Bool { + // List of node types that should have their text be replaced + let replaceableTypes: Set = [ + "identifier", + "property_identifier", + "field_identifier", + "variable_name", + "method_name", + "function_name", + "type_identifier" + ] + return replaceableTypes.contains(node.nodeType ?? "") + } + + deinit { + suggestionController?.close() + if let localEventMonitor = localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + } +} + +extension AutoCompleteCoordinator: SuggestionControllerDelegate { + /// Takes a `CompletionItem` and modifies the text view with the new string + func applyCompletionItem(item: CodeSuggestionEntry) { + guard let cursorPos = textViewController?.cursorPositions.first, + let item = item as? CompletionItem, + let textView = textViewController?.textView else { + return + } + + // Make the updates + let replacementRange = currentNode?.range ?? cursorPos.range + let insertText = LSPCompletionItemsUtil.getInsertText(from: item) + textView.undoManager?.beginUndoGrouping() + textView.replaceString(in: replacementRange, with: insertText) + textView.undoManager?.endUndoGrouping() + + // Set cursor position to end of inserted text + let newCursorRange = NSRange(location: replacementRange.location + insertText.count, length: 0) + textViewController?.setCursorPositions([CursorPosition(range: newCursorRange)]) + + self.onCompletion() + } + + func onCompletion() { } + + func onCursorMove() { + guard let cursorPos = textViewController?.cursorPositions.first, + let suggestionController = suggestionController, + let textView = self.textViewController?.textView, + suggestionController.isVisible + else { + return + } + guard let currentNode = currentNode, + !suggestionController.items.isEmpty else { + self.suggestionController?.close() + return + } + + // Moving to a new token requires a new call to the language server + // We extend the range so that the `contains` can include the end value of + // the token, since its check is exclusive. + let adjustedRange = currentNode.range.shifted(endBy: 1) + if let adjustedRange = adjustedRange, + !adjustedRange.contains(cursorPos.range.location) { + suggestionController.close() + return + } + + // Check if cursor is at the start of the token + if cursorPos.range.location == currentNode.range.location { + currentFilterText = "" + suggestionController.items = completionItems + return + } + + // Filter through the completion items based on how far the cursor is in the token + if cursorPos.range.location > currentNode.range.location { + let selectedRange = NSRange( + location: currentNode.range.location, + length: cursorPos.range.location - currentNode.range.location + ) + if let tokenSubstring = textView.textStorage?.substring(from: selectedRange) { + currentFilterText = tokenSubstring + suggestionController.items = filterCompletionItems(completionItems) + } + } + } + + func onItemSelect(item: CodeSuggestionEntry) { } + + func onClose() { + currentNode = nil + currentFilterText = "" + } +} diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index f8aeb8ebc..2029bd45c 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -27,6 +27,7 @@ class EditorInstance: Hashable { // Public TextViewCoordinator APIs var rangeTranslator: RangeTranslator? + var autoCompleteCoordinator: AutoCompleteCoordinator? // Internal Combine subjects @@ -38,6 +39,7 @@ class EditorInstance: Hashable { self.file = file self.cursorSubject.send(cursorPositions) self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) + self.autoCompleteCoordinator = AutoCompleteCoordinator(file) } func hash(into hasher: inout Hasher) { diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 421adf9d6..384a65536 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -7,6 +7,7 @@ import SwiftUI import CodeEditTextView +import CodeEditSourceEditor struct EditorAreaView: View { @AppSettings(\.general.showEditorPathBar) @@ -52,7 +53,10 @@ struct EditorAreaView: View { if let codeFile = codeFile { EditorAreaFileView( codeFile: codeFile, - textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) + // Linter keeps complaining about types, which is why there are these weird casts + textViewCoordinators: [ + selected.rangeTranslator as Any, selected.autoCompleteCoordinator as Any + ].compactMap({ $0 as? any TextViewCoordinator }) ) .focusedObject(editor) .transformEnvironment(\.edgeInsets) { insets in diff --git a/CodeEdit/Features/LSP/LSPUtil.swift b/CodeEdit/Features/LSP/LSPUtil.swift index 740a82104..b71b94553 100644 --- a/CodeEdit/Features/LSP/LSPUtil.swift +++ b/CodeEdit/Features/LSP/LSPUtil.swift @@ -38,6 +38,22 @@ enum LSPCompletionItemsUtil { return edits } + static func getInsertText(from completionItem: CompletionItem) -> String { + // According to LSP spec, textEdit takes precedence if present, then insertText, then label + if let textEdit = completionItem.textEdit { + switch textEdit { + case .optionA(let edit): + return edit.newText + case .optionB(let insertReplaceEdit): + return insertReplaceEdit.newText + } + } + if let insertText = completionItem.insertText { + return insertText + } + return completionItem.label + } + private static func editOrReplaceItem(edit: TwoTypeOption, _ edits: inout [TextEdit]) { switch edit { case .optionA(let textEdit): diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index b4baa73bb..c6443ddec 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -33,15 +33,15 @@ 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)") -// } + 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)") + } } private func handleRequest(_ request: ServerRequest) { diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 2eaab98d6..9d07ab18b 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -34,69 +34,6 @@ import CodeEditLanguages /// ) /// try await lspService.stopServer(for: .python) /// ``` -/// -/// ## Completion Example -/// -/// ```swift -/// func testCompletion() async throws { -/// do { -/// guard var languageClient = self.languageClient(for: .python) else { -/// print("Failed to get client") -/// throw ServerManagerError.languageClientNotFound -/// } -/// -/// let testFilePathStr = "" -/// let testFileURL = URL(fileURLWithPath: testFilePathStr) -/// -/// // Tell server we opened a document -/// _ = await languageClient.addDocument(testFileURL) -/// -/// // Completion example -/// let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 -/// let completions = try await languageClient.requestCompletion( -/// document: testFileURL.absoluteString, -/// position: textPosition -/// ) -/// switch completions { -/// case .optionA(let completionItems): -/// // Handle the case where completions is an array of CompletionItem -/// print("\n*******\nCompletion Items:\n*******\n") -/// for item in completionItems { -/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( -/// startPosition: textPosition, -/// item: item -/// ) -/// for edit in textEdits { -/// print(edit) -/// } -/// } -/// -/// case .optionB(let completionList): -/// // Handle the case where completions is a CompletionList -/// print("\n*******\nCompletion Items:\n*******\n") -/// for item in completionList.items { -/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( -/// startPosition: textPosition, -/// item: item -/// ) -/// for edit in textEdits { -/// print(edit) -/// } -/// } -/// -/// print(completionList.items[0]) -/// -/// case .none: -/// print("No completions found") -/// } -/// -/// // Close the document -/// _ = await languageClient.closeDocument(testFilePathStr) -/// } catch { -/// print(error) -/// } -/// } -/// ``` @MainActor final class LSPService: ObservableObject { let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") @@ -180,14 +117,42 @@ final class LSPService: ObservableObject { throw LSPError.binaryNotFound } + let taskUuidString = UUID().uuidString + + // Log start message to the activity viewer + let createInfo: [String: Any] = [ + "id": taskUuidString, + "action": "create", + "title": "Starting \(languageId.rawValue) language server", + "isLoading": true + ] logger.info("Starting \(languageId.rawValue) language server") + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createInfo) + + // Attempt to start the language server let server = try await LanguageServer.createServer( for: languageId, with: serverBinary, workspacePath: workspacePath ) languageClients[ClientKey(languageId, workspacePath)] = server + + // Log success message update + let updateInfo: [String: Any] = [ + "id": taskUuidString, + "action": "update", + "title": "Successfully started \(languageId.rawValue) language server", + "isLoading": false + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) + + let deleteInfo: [String: Any] = [ + "id": taskUuidString, + "action": "deleteWithDelay", + "delay": 4.0 + ] logger.info("Successfully started \(languageId.rawValue) language server") + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) self.startListeningToEvents(for: ClientKey(languageId, workspacePath)) return server @@ -218,7 +183,7 @@ final class LSPService: ObservableObject { do { try await languageServer.openDocument(document) } catch { - let uri = await document.languageServerURI + let uri = document.languageServerURI // swiftlint:disable:next line_length self.logger.error("Failed to close document: \(uri ?? "", privacy: .private), language: \(lspLanguage.rawValue). Error \(error)") } diff --git a/CodeEdit/Features/LSP/Views/CompletionItem.swift b/CodeEdit/Features/LSP/Views/CompletionItem.swift new file mode 100644 index 000000000..4e1c938f1 --- /dev/null +++ b/CodeEdit/Features/LSP/Views/CompletionItem.swift @@ -0,0 +1,91 @@ +// +// CompletionItem.swift +// CodeEdit +// +// Created by Abe Malla on 10/05/24. +// + +import SwiftUI +import CodeEditSourceEditor +import LanguageServerProtocol + +// TODO: REMOVE Y OFFSET ON 16 PX? + +// TODO: IMPORT FONT SIZE +let FONT_SIZE: CGFloat = 12 +let fontSizeToImageSize: [CGFloat: CGFloat] = [ + 12: 16.5, + 13: 17.75, // Not sure + 14: 19, // checking this + 16: 22, + 18: 24, +] +let fontSizeToRowHeight: [CGFloat: CGFloat] = [ + 12: 21, + 13: 22, + 14: 23, + 15: 0, // TODO + 16: 26, + 17: 0, // TODO + 18: 28, +] +let fontSizeToRightPadding: [CGFloat: CGFloat] = [ + 12: 13, + 13: 13, + 14: 13, // TODO + 15: 12.5, + 16: 12.5, + 17: 12.5, + 18: 12.5, +] + +extension CompletionItem: @retroactive CodeSuggestionEntry { + public var view: NSView { + NSHostingView( + rootView: HStack(spacing: 0) { + Image(systemName: CompletionItemKind.toSymbolName(kind: self.kind)) + .font(.system(size: fontSizeToImageSize[FONT_SIZE]!)) + .foregroundStyle( + .white, + deprecated == true ? .gray : CompletionItemKind.toSymbolColor(kind: self.kind) + ) + .padding(0) + .padding(.trailing, 2) + + // Main label + HStack(spacing: 0) { + Text(label) + .font(.system(size: FONT_SIZE, design: .monospaced)) + .foregroundStyle(deprecated == true ? .secondary : .primary) + + if let detail = detail { + Text(detail) + .font(.system(size: FONT_SIZE, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(0) + .offset(y: -1) + + Spacer() + + // Right side indicators + HStack(spacing: 6.5) { + if deprecated == true { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: FONT_SIZE + 2)) + .foregroundStyle(.primary, .secondary) + } + if documentation != nil { + Image(systemName: "chevron.right") + .font(.system(size: FONT_SIZE - 2.5)) + .fontWeight(.semibold) + } + } + .padding(.leading, 4) + .padding(.trailing, 6.5) + } + .padding(.horizontal, fontSizeToRightPadding[FONT_SIZE]) + ) + } +} diff --git a/CodeEdit/Features/LSP/Views/CompletionItemKind.swift b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift new file mode 100644 index 000000000..dbe551505 --- /dev/null +++ b/CodeEdit/Features/LSP/Views/CompletionItemKind.swift @@ -0,0 +1,85 @@ +// +// CompletionItemKind.swift +// CodeEdit +// +// Created by Abe Malla on 10/05/24. +// + +import SwiftUI +import LanguageServerProtocol + +extension CompletionItemKind { + static func toSymbolName(kind: CompletionItemKind?) -> String { + let defaultSymbol = "dot.square.fill" + + guard let kind = kind else { + return defaultSymbol + } + + let symbolMap: [CompletionItemKind: String] = [ + .text: "t.square.fill", + .method: "m.square.fill", + .function: "curlybraces.square.fill", + .constructor: "i.square.fill", + .field: "c.square.fill", + .variable: "v.square.fill", + .class: "c.square.fill", + .interface: "i.square.fill", + .module: "m.square.fill", + .property: "p.square.fill", + .unit: "u.square.fill", + .value: "n.square.fill", + .enum: "e.square.fill", + .keyword: "k.square.fill", + .snippet: "s.square.fill", + .color: "c.square.fill", + .file: "d.square.fill", + .reference: "r.square.fill", + .folder: "f.square.fill", + .enumMember: "e.square.fill", + .constant: "k.square.fill", + .struct: "s.square.fill", + .event: "e.square.fill", + .operator: "plus.slash.minus", + .typeParameter: "t.square.fill" + ] + return symbolMap[kind] ?? defaultSymbol + } + + static func toSymbolColor(kind: CompletionItemKind?) -> SwiftUICore.Color { + let defaultColor = Color.gray + + guard let kind = kind else { + return defaultColor + } + + let symbolMap: [CompletionItemKind: SwiftUICore.Color] = [ + .text: Color.blue, + .method: Color.cyan, + .function: Color.blue, + .constructor: Color.teal, + .field: Color.indigo, + .variable: Color.blue, + .class: Color.pink, + .interface: Color.blue, + .module: Color.blue, + .property: Color.purple, + .unit: Color.blue, + .value: Color.blue, + .enum: Color.mint, + .keyword: Color.pink, + .snippet: Color.purple, + .color: Color.blue, + .file: Color.blue, + .reference: Color.blue, + .folder: Color.blue, + .enumMember: Color.blue, + .constant: Color.blue, + .struct: Color.blue, + .event: Color.blue, + .operator: Color.blue, + .typeParameter: Color.blue, + ] + return symbolMap[kind] ?? defaultColor + } +} diff --git a/CodeEdit/Features/Settings/Views/ExternalLink.swift b/CodeEdit/Features/Settings/Views/ExternalLink.swift index 9995df553..4a6453184 100644 --- a/CodeEdit/Features/Settings/Views/ExternalLink.swift +++ b/CodeEdit/Features/Settings/Views/ExternalLink.swift @@ -55,7 +55,7 @@ struct ExternalLink: View { @ViewBuilder content: @escaping () -> Content, title: String? = nil, subtitle: String? = nil, - @ViewBuilder icon: @escaping() -> Icon = { EmptyView() } + @ViewBuilder icon: @escaping () -> Icon = { EmptyView() } ) { self.showInFinder = showInFinder self.title = title