diff --git a/.all-contributorsrc b/.all-contributorsrc index f39c761437..0031e32fd9 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -794,6 +794,25 @@ "contributions": [ "code" ] + }, + { + "login": "rustemd02", + "name": "rustemd02", + "avatar_url": "https://avatars.githubusercontent.com/u/11714456?v=4", + "profile": "https://github.com/rustemd02", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "SimonKudsk", + "name": "Simon Kudsk", + "avatar_url": "https://avatars.githubusercontent.com/u/10168417?v=4", + "profile": "https://github.com/SimonKudsk", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2a95476a01..448a0cc391 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -419,7 +419,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6CFE18222DA59C9F00A7B796 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1622,7 +1622,7 @@ repositoryURL = "https://github.com/CodeEditApp/CodeEditSymbols"; requirement = { kind = exactVersion; - version = 0.2.2; + version = 0.2.3; }; }; 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */ = { @@ -1745,12 +1745,12 @@ version = 1.0.1; }; }; - 6CFE18222DA59C9F00A7B796 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.11.0; + kind = exactVersion; + version = 0.13.2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 305b012067..beb5a675d0 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", "state" : { - "revision" : "f444927ab70015f4b76f119f6fc5d0e358fcd77a", - "version" : "0.11.0" + "revision" : "30eb8a8cf3b291c91da04cfbc6683bee643b86a6", + "version" : "0.13.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSymbols", "state" : { - "revision" : "a794528172314f9be5d838f8579c4435895e0988", - "version" : "0.2.2" + "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", + "version" : "0.2.3" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", - "version" : "0.8.2" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 078d85d5bf..2e0f824d11 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -103,12 +103,12 @@ final class CEWorkspaceFileManager { return nil } - // Drill down towards the file, indexing any directories needed. - // If file is not in the `workspaceSettingsFolderURL` or subdirectories, exit. - guard url.absoluteString.starts(with: folderUrl.absoluteString), - url.pathComponents.count > folderUrl.pathComponents.count else { + // If file is not in the `folderUrl` or subdirectories, exit. + guard folderUrl.containsSubPath(url) else { return nil } + + // Drill down towards the file, indexing any directories needed. let pathComponents = url.pathComponents.dropFirst(folderUrl.pathComponents.count) var currentURL = folderUrl diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift index 8ff0b02494..21d5f661a9 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift @@ -17,8 +17,8 @@ final class CEWorkspaceSettings: ObservableObject { private(set) var folderURL: URL - private var settingsURL: URL { - folderURL.appending(path: "settings").appending(path: "json") + var settingsURL: URL { + folderURL.appending(path: "settings").appendingPathExtension("json") } init(workspaceURL: URL) { @@ -54,7 +54,17 @@ final class CEWorkspaceSettings: ObservableObject { /// Save``CEWorkspaceSettingsManager`` model to `.codeedit/settings.json` func savePreferences() throws { // If the user doesn't have any settings to save, don't save them. - guard !settings.isEmpty() else { return } + guard !settings.isEmpty() else { + // Settings is empty, remove the file & directory if it's empty. + if fileManager.fileExists(atPath: settingsURL.path()) { + try fileManager.removeItem(at: settingsURL) + + if try fileManager.contentsOfDirectory(atPath: folderURL.path()).isEmpty { + try fileManager.removeItem(at: folderURL) + } + } + return + } if !fileManager.fileExists(atPath: folderURL.path()) { try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true) diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift index f3f47a3eb2..6e7f84057c 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/CETaskFormView.swift @@ -12,7 +12,6 @@ struct CETaskFormView: View { @ObservedObject var task: CETask @State private var selectedEnvID: UUID? - @StateObject var settingsViewModel = SettingsViewModel() var body: some View { Form { Section { @@ -85,7 +84,6 @@ struct CETaskFormView: View { } } .formStyle(.grouped) - .environmentObject(settingsViewModel) } func removeSelectedEnv() { @@ -100,7 +98,3 @@ struct CETaskFormView: View { }) } } - -// #Preview { -// CETaskFormView() -// } diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift index 682d63b256..451ed2a38f 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/CEWorkspaceSettingsView.swift @@ -13,8 +13,6 @@ struct CEWorkspaceSettingsView: View { @EnvironmentObject var workspaceSettingsManager: CEWorkspaceSettings @EnvironmentObject var workspace: WorkspaceDocument - @StateObject var settingsViewModel = SettingsViewModel() - @State var selectedTaskID: UUID? @State var showAddTaskSheet: Bool = false @@ -68,7 +66,6 @@ struct CEWorkspaceSettingsView: View { } .padding() } - .environmentObject(settingsViewModel) .sheet(isPresented: $showAddTaskSheet) { if let selectedTaskIndex = workspaceSettingsManager.settings.tasks.firstIndex(where: { $0.id == selectedTaskID diff --git a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift b/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift index 3efbec03f5..8d12b39f5f 100644 --- a/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift +++ b/CodeEdit/Features/CEWorkspaceSettings/Views/EditCETaskView.swift @@ -23,12 +23,16 @@ struct EditCETaskView: View { Divider() HStack { Button(role: .destructive) { - workspaceSettingsManager.settings.tasks.removeAll(where: { - $0.id == task.id - }) - try? workspaceSettingsManager.savePreferences() - taskManager.deleteTask(taskID: task.id) - self.dismiss() + do { + workspaceSettingsManager.settings.tasks.removeAll(where: { + $0.id == task.id + }) + try workspaceSettingsManager.savePreferences() + taskManager.deleteTask(taskID: task.id) + self.dismiss() + } catch { + NSAlert(error: error).runModal() + } } label: { Text("Delete") .foregroundStyle(.red) @@ -38,8 +42,12 @@ struct EditCETaskView: View { Spacer() Button { - try? workspaceSettingsManager.savePreferences() - self.dismiss() + do { + try workspaceSettingsManager.savePreferences() + self.dismiss() + } catch { + NSAlert(error: error).runModal() + } } label: { Text("Done") .frame(minWidth: 56) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 8fd36004b5..2b3fcfd241 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -50,9 +50,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// See ``CodeEditSourceEditor/CombineCoordinator``. @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() - /// Set by ``LanguageServer`` when initialized. - @Published var lspCoordinator: LSPContentCoordinator? - /// Used to override detected languages. @Published var language: CodeLanguage? @@ -65,6 +62,9 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// Document-specific overridden line wrap preference. @Published var wrapLines: Bool? + /// Set up by ``LanguageServer``, conforms this type to ``LanguageServerDocument``. + @Published var languageServerObjects: LanguageServerDocumentObjects = .init() + /// The type of data this file document contains. /// /// If its text content is not nil, a `text` UTType is returned. @@ -83,9 +83,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { return type } - /// A stable string to use when identifying documents with language servers. - var languageServerURI: String? { fileURL?.absolutePath } - /// Specify options for opening the file such as the initial cursor positions. /// Nulled by ``CodeFileView`` on first load. var openOptions: OpenOptions? @@ -208,6 +205,10 @@ final class CodeFileDocument: NSDocument, ObservableObject { } } + /// Determines the code language of the document. + /// Use ``CodeFileDocument/language`` for the default value before using this. That property is used to override + /// the file's language. + /// - Returns: The detected code language. func getLanguage() -> CodeLanguage { guard let url = fileURL else { return .default @@ -223,3 +224,13 @@ final class CodeFileDocument: NSDocument, ObservableObject { fileURL?.findWorkspace() } } + +// MARK: LanguageServerDocument + +extension CodeFileDocument: LanguageServerDocument { + /// A stable string to use when identifying documents with language servers. + /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. + var languageServerURI: String? { + fileURL?.lspURI + } +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index 034d3de75e..256fb08187 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift @@ -59,8 +59,11 @@ final class CodeEditDocumentController: NSDocumentController { display displayDocument: Bool, completionHandler: @escaping (NSDocument?, Bool, Error?) -> Void ) { - super.openDocument(withContentsOf: url, display: displayDocument) { document, documentWasAlreadyOpen, error in + guard !openFileInExistingWorkspace(url: url) else { + return + } + super.openDocument(withContentsOf: url, display: displayDocument) { document, documentWasAlreadyOpen, error in if let document { self.addDocument(document) } else { @@ -68,11 +71,33 @@ final class CodeEditDocumentController: NSDocumentController { print("Unable to open document '\(url)': \(errorMessage)") } - RecentProjectsStore.documentOpened(at: url) + RecentProjectsStore.shared.documentOpened(at: url) completionHandler(document, documentWasAlreadyOpen, error) } } + /// Attempt to open the file URL in an open workspace, finding the nearest workspace to open it in if possible. + /// - Parameter url: The file URL to open. + /// - Returns: True, if the document was opened in a workspace. + private func openFileInExistingWorkspace(url: URL) -> Bool { + guard !url.isFolder else { return false } + let workspaces = documents.compactMap({ $0 as? WorkspaceDocument }) + + // Check open workspaces for the file being opened. Sorted by shared components with the url so we + // open the nearest workspace possible. + for workspace in workspaces.sorted(by: { + ($0.fileURL?.sharedComponents(url) ?? 0) > ($1.fileURL?.sharedComponents(url) ?? 0) + }) { + // createIfNotFound will still return `nil` if the files don't share a common ancestor. + if let newFile = workspace.workspaceFileManager?.getFile(url.absolutePath, createIfNotFound: true) { + workspace.editorManager?.openTab(item: newFile) + workspace.showWindows() + return true + } + } + return false + } + override func removeDocument(_ document: NSDocument) { super.removeDocument(document) diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift new file mode 100644 index 0000000000..e797f9b5bd --- /dev/null +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift @@ -0,0 +1,179 @@ +// +// CodeEditWindowController+Panels.swift +// CodeEdit +// +// Created by Simon Kudsk on 11/05/2025. +// + +import SwiftUI + +extension CodeEditWindowController { + @objc + func objcToggleFirstPanel() { + toggleFirstPanel(shouldAnimate: true) + } + + /// Toggles the navigator pane, optionally without animation. + func toggleFirstPanel(shouldAnimate: Bool = true) { + guard let firstSplitView = splitViewController?.splitViewItems.first else { return } + + if shouldAnimate { + // Standard animated toggle + firstSplitView.animator().isCollapsed.toggle() + } else { + // Instant toggle (no animation) + firstSplitView.isCollapsed.toggle() + } + + splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed) + } + + @objc + func objcToggleLastPanel() { + toggleLastPanel(shouldAnimate: true) + } + + func toggleLastPanel(shouldAnimate: Bool = true) { + guard let lastSplitView = splitViewController?.splitViewItems.last else { + return + } + + if shouldAnimate { + // Standard animated toggle + NSAnimationContext.runAnimationGroup { _ in + lastSplitView.animator().isCollapsed.toggle() + } + } else { + // Instant toggle (no animation) + lastSplitView.isCollapsed.toggle() + } + + splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed) + } + + // PanelDescriptor, used for an array of panels, for use with "Hide interface". + private struct PanelDescriptor { + /// Returns the current `isCollapsed` value for the panel. + let isCollapsed: () -> Bool + /// Returns the last stored previous state (or `nil` if none). + let getPrevCollapsed: () -> Bool? + /// Stores a new previous state (`nil` to clear). + let setPrevCollapsed: (Bool?) -> Void + /// Performs the actual toggle action for the panel. + let toggle: () -> Void + } + + // The panels which "Hide interface" should interact with. + private var panels: [PanelDescriptor] { + [ + PanelDescriptor( + isCollapsed: { self.navigatorCollapsed }, + getPrevCollapsed: { self.prevNavigatorCollapsed }, + setPrevCollapsed: { self.prevNavigatorCollapsed = $0 }, + toggle: { self.toggleFirstPanel(shouldAnimate: false) } + ), + PanelDescriptor( + isCollapsed: { self.inspectorCollapsed }, + getPrevCollapsed: { self.prevInspectorCollapsed }, + setPrevCollapsed: { self.prevInspectorCollapsed = $0 }, + toggle: { self.toggleLastPanel(shouldAnimate: false) } + ), + PanelDescriptor( + isCollapsed: { self.workspace?.utilityAreaModel?.isCollapsed ?? true }, + getPrevCollapsed: { self.prevUtilityAreaCollapsed }, + setPrevCollapsed: { self.prevUtilityAreaCollapsed = $0 }, + toggle: { CommandManager.shared.executeCommand("open.drawer") } + ), + PanelDescriptor( + isCollapsed: { self.toolbarCollapsed }, + getPrevCollapsed: { self.prevToolbarCollapsed }, + setPrevCollapsed: { self.prevToolbarCollapsed = $0 }, + toggle: { self.toggleToolbar() } + ) + ] + } + + /// Returns `true` if at least one panel that was visible is still collapsed, meaning the interface is still hidden + func isInterfaceStillHidden() -> Bool { + // Some panels do not yet have a remembered state + if panels.contains(where: { $0.getPrevCollapsed() == nil }) { + // Hidden only if all panels are collapsed + return panels.allSatisfy { $0.isCollapsed() } + } + + // All panels have a remembered state. Check if any that were visible are still collapsed + let stillHidden = panels.contains { descriptor in + guard let prev = descriptor.getPrevCollapsed() else { return false } + return !prev && descriptor.isCollapsed() + } + + // If the interface has been restored, reset the remembered states + if !stillHidden { + DispatchQueue.main.async { [weak self] in + self?.resetStoredInterfaceCollapseState() + } + } + + return stillHidden + } + + /// Function for toggling the interface elements on or off + /// + /// - Parameter shouldHide: Pass `true` to hide all interface panels (and remember their current states), + /// or `false` to restore them to how they were before hiding. + func toggleInterface(shouldHide: Bool) { + // Store the current layout before hiding + if shouldHide { + storeInterfaceCollapseState() + } + + // Iterate over all panels and update their state as needed + for panel in panels { + let targetState = determineDesiredCollapseState( + shouldHide: shouldHide, + currentlyCollapsed: panel.isCollapsed(), + previouslyCollapsed: panel.getPrevCollapsed() + ) + if panel.isCollapsed() != targetState { + panel.toggle() + } + } + } + + /// Calculates the collapse state an interface element should have after a hide / show toggle. + /// - Parameters: + /// - shouldHide: `true` when we’re hiding the whole interface. + /// - currentlyCollapsed: The panels current state + /// - previouslyCollapsed: The state we saved the last time we hid the UI, if any. + /// - Returns: `true` for visible element, `false` for collapsed element + func determineDesiredCollapseState(shouldHide: Bool, currentlyCollapsed: Bool, previouslyCollapsed: Bool?) -> Bool { + // If ShouldHide, everything should close + if shouldHide { + return true + } + + // If not hiding, and not currently collapsed, the panel should remain as such. + if !currentlyCollapsed { + return false + } + + // If the panel is currently collapsed and we are "showing" or "restoring": + // Option 1: Restore to its previously remembered state if available. + // Option 2: If no previously remembered state, default to making it visible (not collapsed). + return previouslyCollapsed ?? false + } + + /// Function for storing the current interface visibility states + func storeInterfaceCollapseState() { + for panel in panels { + panel.setPrevCollapsed(panel.isCollapsed()) + } + } + + /// Function for resetting the stored interface visibility states + func resetStoredInterfaceCollapseState() { + for panel in panels { + panel.setPrevCollapsed(nil) + } + } +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index e7d5f02822..4b434fbd44 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -56,10 +56,11 @@ extension CodeEditWindowController { func toggleToolbar() { toolbarCollapsed.toggle() + workspace?.addToWorkspaceState(key: .toolbarCollapsed, value: toolbarCollapsed) updateToolbarVisibility() } - private func updateToolbarVisibility() { + func updateToolbarVisibility() { if toolbarCollapsed { window?.titleVisibility = .visible window?.title = workspace?.workspaceFileManager?.folderUrl.lastPathComponent ?? "Empty" @@ -92,7 +93,7 @@ extension CodeEditWindowController { toolbarItem.toolTip = "Hide or show the Navigator" toolbarItem.isBordered = true toolbarItem.target = self - toolbarItem.action = #selector(self.toggleFirstPanel) + toolbarItem.action = #selector(self.objcToggleFirstPanel) toolbarItem.image = NSImage( systemSymbolName: "sidebar.leading", accessibilityDescription: nil @@ -106,7 +107,7 @@ extension CodeEditWindowController { toolbarItem.toolTip = "Hide or show the Inspectors" toolbarItem.isBordered = true toolbarItem.target = self - toolbarItem.action = #selector(self.toggleLastPanel) + toolbarItem.action = #selector(self.objcToggleLastPanel) toolbarItem.image = NSImage( systemSymbolName: "sidebar.trailing", accessibilityDescription: nil diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index 3d581f079f..34543e8be1 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -10,9 +10,15 @@ import SwiftUI import Combine final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, ObservableObject, NSWindowDelegate { - @Published var navigatorCollapsed = false - @Published var inspectorCollapsed = false - @Published var toolbarCollapsed = false + @Published var navigatorCollapsed: Bool = false + @Published var inspectorCollapsed: Bool = false + @Published var toolbarCollapsed: Bool = false + + // These variables store the state of the windows when using "Hide interface" + @Published var prevNavigatorCollapsed: Bool? + @Published var prevInspectorCollapsed: Bool? + @Published var prevUtilityAreaCollapsed: Bool? + @Published var prevToolbarCollapsed: Bool? private var panelOpen = false @@ -38,6 +44,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs window?.delegate = self guard let workspace else { return } self.workspace = workspace + self.toolbarCollapsed = workspace.getFromWorkspaceState(.toolbarCollapsed) as? Bool ?? false guard let splitViewController = setupSplitView(with: workspace) else { fatalError("Failed to set up content view.") } @@ -67,6 +74,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs ] setupToolbar() + updateToolbarVisibility() registerCommands() } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift index 88e7dbc9b0..baade6dfdf 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift @@ -9,26 +9,6 @@ import SwiftUI import Combine extension CodeEditWindowController { - @objc - func toggleFirstPanel() { - guard let firstSplitView = splitViewController?.splitViewItems.first else { return } - firstSplitView.animator().isCollapsed.toggle() - splitViewController?.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed) - } - - @objc - func toggleLastPanel() { - guard let lastSplitView = splitViewController?.splitViewItems.last else { - return - } - - NSAnimationContext.runAnimationGroup { _ in - lastSplitView.animator().isCollapsed.toggle() - } - - splitViewController?.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed) - } - /// These are example items that added as commands to command palette func registerCommands() { CommandManager.shared.addCommand( diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift index 67aedd6909..7a233fe4da 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceStateKey.swift @@ -14,4 +14,5 @@ enum WorkspaceStateKey: String { case splitViewWidth case navigatorCollapsed case inspectorCollapsed + case toolbarCollapsed } diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift b/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift index 5d719e72c2..ce53d07b83 100644 --- a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift +++ b/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift @@ -21,15 +21,19 @@ struct EditorJumpBarView: View { @Environment(\.controlActiveState) private var activeState + @Binding var codeFile: CodeFileDocument? + static let height = 28.0 init( file: CEWorkspaceFile?, shouldShowTabBar: Bool, + codeFile: Binding, tappedOpenFile: @escaping (CEWorkspaceFile) -> Void ) { self.file = file ?? nil self.shouldShowTabBar = shouldShowTabBar + self._codeFile = codeFile self.tappedOpenFile = tappedOpenFile } @@ -75,7 +79,7 @@ struct EditorJumpBarView: View { } .safeAreaInset(edge: .trailing, spacing: 0) { if !shouldShowTabBar { - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: $codeFile) } } .frame(height: Self.height, alignment: .center) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift index 796f661aa4..156983061f 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift @@ -8,6 +8,11 @@ import SwiftUI struct EditorTabBarTrailingAccessories: View { + @AppSettings(\.textEditing.wrapLinesToEditorWidth) + var wrapLinesToEditorWidth + @AppSettings(\.textEditing.showMinimap) + var showMinimap + @Environment(\.splitEditor) var splitEditor @@ -21,15 +26,49 @@ struct EditorTabBarTrailingAccessories: View { @EnvironmentObject private var editor: Editor + @Binding var codeFile: CodeFileDocument? + var body: some View { - HStack(spacing: 0) { + HStack(spacing: 6) { + // Once more options are implemented that are available for non-code documents, remove this if statement + if let codeFile { + editorOptionsMenu(codeFile: codeFile) + Divider() + .padding(.vertical, 10) + } splitviewButton } + .buttonStyle(.icon) + .disabled(editorManager.isFocusingActiveEditor) + .opacity(editorManager.isFocusingActiveEditor ? 0.5 : 1) .padding(.horizontal, 7) .opacity(activeState != .inactive ? 1.0 : 0.5) .frame(maxHeight: .infinity) // Fill out vertical spaces. } + func editorOptionsMenu(codeFile: CodeFileDocument) -> some View { + // This is a button so it gets the same styling from the Group in `body`. + Button(action: {}, label: { Image(systemName: "slider.horizontal.3") }) + .overlay { + Menu { + Toggle("Show Minimap", isOn: $showMinimap) + .keyboardShortcut("M", modifiers: [.command, .shift, .control]) + Divider() + Toggle( + "Wrap Lines", + isOn: Binding( + get: { codeFile.wrapLines ?? wrapLinesToEditorWidth }, + set: { + codeFile.wrapLines = $0 + } + ) + ) + } label: {} + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + } + } + var splitviewButton: some View { Group { switch (editor.parent?.axis, modifierKeys.contains(.option)) { @@ -53,9 +92,6 @@ struct EditorTabBarTrailingAccessories: View { EmptyView() } } - .buttonStyle(.icon) - .disabled(editorManager.isFocusingActiveEditor) - .opacity(editorManager.isFocusingActiveEditor ? 0.5 : 1) } func split(edge: Edge) { @@ -73,6 +109,6 @@ struct EditorTabBarTrailingAccessories: View { struct TabBarTrailingAccessories_Previews: PreviewProvider { static var previews: some View { - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: .constant(nil)) } } diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift index 879a990208..e080d1dffc 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarView.swift @@ -9,6 +9,7 @@ import SwiftUI struct EditorTabBarView: View { let hasTopInsets: Bool + @Binding var codeFile: CodeFileDocument? /// The height of tab bar. /// I am not making it a private variable because it may need to be used in outside views. static let height = 28.0 @@ -21,7 +22,7 @@ struct EditorTabBarView: View { .accessibilityElement(children: .contain) .accessibilityLabel("Tab Bar") .accessibilityIdentifier("TabBar") - EditorTabBarTrailingAccessories() + EditorTabBarTrailingAccessories(codeFile: $codeFile) .padding(.top, hasTopInsets ? -1 : 0) } .frame(height: EditorTabBarView.height - (hasTopInsets ? 1 : 0)) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index f45f4d1074..ef01751319 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -19,9 +19,13 @@ struct CodeFileView: View { /// The current cursor positions in the view @State private var cursorPositions: [CursorPosition] = [] + @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() + /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] + @State private var highlightProviders: [any HighlightProviding] = [] + @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @AppSettings(\.textEditing.indentOption) @@ -44,6 +48,12 @@ struct CodeFileView: View { var bracketEmphasis @AppSettings(\.textEditing.useSystemCursor) var useSystemCursor + @AppSettings(\.textEditing.showMinimap) + var showMinimap + @AppSettings(\.textEditing.reformatAtColumn) + var reformatAtColumn + @AppSettings(\.textEditing.showReformattingGuide) + var showReformattingGuide @Environment(\.colorScheme) private var colorScheme @@ -60,9 +70,10 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) + self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator] - + [codeFile.lspCoordinator].compactMap({ $0 }) + + [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 }) self.isEditable = isEditable if let openOptions = codeFile.openOptions { @@ -70,6 +81,8 @@ struct CodeFileView: View { self.cursorPositions = openOptions.cursorPositions } + updateHighlightProviders() + codeFile .contentCoordinator .textUpdatePublisher @@ -117,7 +130,7 @@ struct CodeFileView: View { editorOverscroll: overscroll.overscrollPercentage, cursorPositions: $cursorPositions, useThemeBackground: useThemeBackground, - highlightProviders: [treeSitter], + highlightProviders: highlightProviders, contentInsets: edgeInsets.nsEdgeInsets, additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0), isEditable: isEditable, @@ -125,7 +138,10 @@ struct CodeFileView: View { bracketPairEmphasis: getBracketPairEmphasis(), useSystemCursor: useSystemCursor, undoManager: undoManager, - coordinators: textViewCoordinators + coordinators: textViewCoordinators, + showMinimap: showMinimap, + reformatAtColumn: reformatAtColumn, + showReformattingGuide: showReformattingGuide ) .id(codeFile.fileURL) .background { @@ -141,6 +157,10 @@ struct CodeFileView: View { .onChange(of: settingsFont) { newFontSetting in font = newFontSetting.current } + .onReceive(codeFile.$languageServerObjects) { languageServerObjects in + // This will not be called in single-file views (for now) but is safe to listen to either way + updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider) + } } /// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme. @@ -163,6 +183,12 @@ struct CodeFileView: View { return .underline(color: color) } } + + /// Updates the highlight providers array. + /// - Parameter lspHighlightProvider: The language server provider, if available. + private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) { + highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient] + } } // This extension is kept here because it should not be used elsewhere in the app and may cause confusion diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 5e3dd85570..96ff8bf210 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -104,7 +104,7 @@ struct EditorAreaView: View { .background(.clear) } if shouldShowTabBar { - EditorTabBarView(hasTopInsets: topSafeArea > 0) + EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: $codeFile) .id("TabBarView" + editor.id.uuidString) .environmentObject(editor) Divider() @@ -112,7 +112,8 @@ struct EditorAreaView: View { if showEditorJumpBar { EditorJumpBarView( file: editor.selectedTab?.file, - shouldShowTabBar: shouldShowTabBar + shouldShowTabBar: shouldShowTabBar, + codeFile: $codeFile ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { editor?.openTab(file: newFile, at: index) diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift index 1913af4536..704eddb2c9 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift @@ -18,10 +18,8 @@ struct HistoryPopoverView: View { var body: some View { VStack { CommitDetailsHeaderView(commit: commit) - .padding(.horizontal) Divider() - .padding(.horizontal) VStack(alignment: .leading, spacing: 0) { // TODO: Implementation Needed @@ -71,6 +69,8 @@ struct HistoryPopoverView: View { }, icon: { Image(systemName: image) .frame(width: 16, alignment: .center) + .padding(.leading, -2.5) + .padding(.trailing, 2.5) }) .frame(maxWidth: .infinity, alignment: .leading) .foregroundColor(isHovering && isEnabled ? .white : .primary) diff --git a/CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift similarity index 87% rename from CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift rename to CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift index dc17481e61..aed2a7adae 100644 --- a/CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift +++ b/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift @@ -19,7 +19,7 @@ import LanguageServerProtocol /// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class /// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then /// chunked into 250ms timed groups before being sent to the ``LanguageServer``. -class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { +class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { // Required to avoid a large_tuple lint error private struct SequenceElement: Sendable { let uri: String @@ -28,25 +28,27 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { } private var editedRange: LSPRange? - private var stream: AsyncStream? private var sequenceContinuation: AsyncStream.Continuation? private var task: Task? - weak var languageServer: LanguageServer? + weak var languageServer: LanguageServer? var documentURI: String /// Initializes a content coordinator, and begins an async stream of updates - init(documentURI: String, languageServer: LanguageServer) { + init(documentURI: String, languageServer: LanguageServer) { self.documentURI = documentURI self.languageServer = languageServer - self.stream = AsyncStream { continuation in - self.sequenceContinuation = continuation - } + + setUpUpdatesTask() } func setUpUpdatesTask() { task?.cancel() - guard let stream else { return } + // Create this stream here so it's always set up when the text view is set up, rather than only once on init. + let stream = AsyncStream { continuation in + self.sequenceContinuation = continuation + } + task = Task.detached { [weak self] in // Send edit events every 250ms for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) { diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift new file mode 100644 index 0000000000..2e391fba4b --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift @@ -0,0 +1,172 @@ +// +// SemanticTokenHighlightProvider.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor +import CodeEditTextView +import CodeEditLanguages + +/// Provides semantic token information from a language server for a source editor view. +/// +/// This class works in tangent with the ``LanguageServer`` class to ensure we don't unnecessarily request new tokens +/// if the document isn't updated. The ``LanguageServer`` will call the +/// ``SemanticTokenHighlightProvider/documentDidChange`` method, which in turn refreshes the semantic token storage. +/// +/// That behavior may not be intuitive due to the +/// ``SemanticTokenHighlightProvider/applyEdit(textView:range:delta:completion:)`` method. One might expect this class +/// to respond to that method immediately, but it does not. It instead stores the completion passed in that method until +/// it can respond to the edit with invalidated indices. +final class SemanticTokenHighlightProvider< + Storage: GenericSemanticTokenStorage, + DocumentType: LanguageServerDocument +>: HighlightProviding { + enum HighlightError: Error { + case lspRangeFailure + } + + typealias EditCallback = @MainActor (Result) -> Void + typealias HighlightCallback = @MainActor (Result<[HighlightRange], any Error>) -> Void + + private let tokenMap: SemanticTokenMap + private let documentURI: String + private weak var languageServer: LanguageServer? + private weak var textView: TextView? + + private var lastEditCallback: EditCallback? + private var pendingHighlightCallbacks: [HighlightCallback] = [] + private var storage: Storage + + var documentRange: NSRange { + textView?.documentRange ?? .zero + } + + init(tokenMap: SemanticTokenMap, languageServer: LanguageServer, documentURI: String) { + self.tokenMap = tokenMap + self.languageServer = languageServer + self.documentURI = documentURI + self.storage = Storage() + } + + // MARK: - Language Server Content Lifecycle + + /// Called when the language server finishes sending a document update. + /// + /// This method first checks if this object has any semantic tokens. If not, requests new tokens and responds to the + /// `pendingHighlightCallbacks` queue with cancellation errors, causing the highlighter to re-query those indices. + /// + /// If this object already has some tokens, it determines whether or not we can request a token delta and + /// performs the request. + func documentDidChange() async throws { + guard let languageServer, let textView else { + return + } + + guard storage.hasReceivedData else { + // We have no semantic token info, request it! + try await requestTokens(languageServer: languageServer, textView: textView) + await MainActor.run { + for callback in pendingHighlightCallbacks { + callback(.failure(HighlightProvidingError.operationCancelled)) + } + pendingHighlightCallbacks.removeAll() + } + return + } + + // The document was updated. Update our token cache and send the invalidated ranges for the editor to handle. + if let lastResultId = storage.lastResultId { + try await requestDeltaTokens(languageServer: languageServer, textView: textView, lastResultId: lastResultId) + return + } + + try await requestTokens(languageServer: languageServer, textView: textView) + } + + // MARK: - LSP Token Requests + + /// Requests and applies a token delta. Requires a previous response identifier. + private func requestDeltaTokens( + languageServer: LanguageServer, + textView: TextView, + lastResultId: String + ) async throws { + guard let response = try await languageServer.requestSemanticTokens( + for: documentURI, + previousResultId: lastResultId + ) else { + return + } + switch response { + case let .optionA(tokenData): + await applyEntireResponse(tokenData, callback: lastEditCallback) + case let .optionB(deltaData): + await applyDeltaResponse(deltaData, callback: lastEditCallback, textView: textView) + } + } + + /// Requests and applies tokens for an entire document. This does not require a previous response id, and should be + /// used in place of `requestDeltaTokens` when that's the case. + private func requestTokens(languageServer: LanguageServer, textView: TextView) async throws { + guard let response = try await languageServer.requestSemanticTokens(for: documentURI) else { + return + } + await applyEntireResponse(response, callback: lastEditCallback) + } + + // MARK: - Apply LSP Response + + /// Applies a delta response from the LSP to our storage. + private func applyDeltaResponse(_ data: SemanticTokensDelta, callback: EditCallback?, textView: TextView?) async { + let lspRanges = storage.applyDelta(data) + lastEditCallback = nil // Don't use this callback again. + await MainActor.run { + let ranges = lspRanges.compactMap { textView?.nsRangeFrom($0) } + callback?(.success(IndexSet(ranges: ranges))) + } + } + + private func applyEntireResponse(_ data: SemanticTokens, callback: EditCallback?) async { + storage.setData(data) + lastEditCallback = nil // Don't use this callback again. + await callback?(.success(IndexSet(integersIn: documentRange))) + } + + // MARK: - Highlight Provider Conformance + + func setUp(textView: TextView, codeLanguage: CodeLanguage) { + // Send off a request to get the initial token data + self.textView = textView + Task { + try await self.documentDidChange() + } + } + + func applyEdit(textView: TextView, range: NSRange, delta: Int, completion: @escaping EditCallback) { + if let lastEditCallback { + lastEditCallback(.success(IndexSet())) // Don't throw a cancellation error + } + lastEditCallback = completion + } + + func queryHighlightsFor(textView: TextView, range: NSRange, completion: @escaping HighlightCallback) { + guard storage.hasReceivedData else { + pendingHighlightCallbacks.append(completion) + return + } + + guard let lspRange = textView.lspRangeFrom(nsRange: range) else { + completion(.failure(HighlightError.lspRangeFailure)) + return + } + let rawTokens = storage.getTokensFor(range: lspRange) + let highlights = tokenMap + .decode(tokens: rawTokens, using: textView) + .filter({ $0.capture != nil || !$0.modifiers.isEmpty }) + completion(.success(highlights)) + } +} diff --git a/CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift similarity index 80% rename from CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift rename to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift index 5a196cf60f..317068a2d8 100644 --- a/CodeEdit/Features/LSP/Editor/SemanticTokenMap.swift +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMap.swift @@ -45,20 +45,31 @@ struct SemanticTokenMap: Sendable { // swiftlint:enable line_length /// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor. /// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`. /// - Parameters: - /// - tokens: Semantic tokens from a language server. + /// - tokens: Encoded semantic tokens type from a language server. /// - rangeProvider: The provider to use to translate token ranges to text view ranges. /// - Returns: An array of decoded highlight ranges. @MainActor func decode(tokens: SemanticTokens, using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] { - tokens.decode().compactMap { token in + return decode(tokens: tokens.decode(), using: rangeProvider) + } + + /// Decodes the compressed semantic token data into a `HighlightRange` type for use in an editor. + /// This is marked main actor to prevent runtime errors, due to the use of the actor-isolated `rangeProvider`. + /// - Parameters: + /// - tokens: Decoded semantic tokens from a language server. + /// - rangeProvider: The provider to use to translate token ranges to text view ranges. + /// - Returns: An array of decoded highlight ranges. + @MainActor + func decode(tokens: [SemanticToken], using rangeProvider: SemanticTokenMapRangeProvider) -> [HighlightRange] { + tokens.compactMap { token in guard let range = rangeProvider.nsRangeFrom(line: token.line, char: token.char, length: token.length) else { return nil } + // Only modifiers are bit packed, capture types are given as a simple index into the ``tokenTypeMap`` let modifiers = decodeModifier(token.modifiers) - // Capture types are indicated by the index of the set bit. - let type = token.type > 0 ? Int(token.type.trailingZeroBitCount) : -1 // Don't try to decode 0 + let type = Int(token.type) let capture = tokenTypeMap.indices.contains(type) ? tokenTypeMap[type] : nil return HighlightRange( diff --git a/CodeEdit/Features/LSP/Editor/SemanticTokenMapRangeProvider.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift similarity index 100% rename from CodeEdit/Features/LSP/Editor/SemanticTokenMapRangeProvider.swift rename to CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenMapRangeProvider.swift diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift new file mode 100644 index 0000000000..ecfcb39325 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift @@ -0,0 +1,25 @@ +// +// GenericSemanticTokenStorage.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor + +/// Defines a protocol for an object to provide storage for semantic tokens. +/// +/// There is only one concrete type that conforms to this in CE, but this protocol is useful in testing. +/// See ``SemanticTokenStorage``. +protocol GenericSemanticTokenStorage: AnyObject { + var lastResultId: String? { get } + var hasReceivedData: Bool { get } + + init() + + func getTokensFor(range: LSPRange) -> [SemanticToken] + func setData(_ data: borrowing SemanticTokens) + func applyDelta(_ deltas: SemanticTokensDelta) -> [SemanticTokenRange] +} diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift new file mode 100644 index 0000000000..6a7bfff6dc --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift @@ -0,0 +1,13 @@ +// +// SemanticTokenRange.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +/// Represents the range of a semantic token. +struct SemanticTokenRange { + let line: UInt32 + let char: UInt32 + let length: UInt32 +} diff --git a/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift new file mode 100644 index 0000000000..3faeae2505 --- /dev/null +++ b/CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift @@ -0,0 +1,180 @@ +// +// SemanticTokenStorage.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import LanguageServerProtocol +import CodeEditSourceEditor + +/// This class provides storage for semantic token data. +/// +/// The LSP spec requires that clients keep the original compressed data to apply delta edits. Delta updates may +/// appear as a delta to a single number in the compressed array. This class maintains the current state of compressed +/// tokens and their decoded counterparts. It supports applying delta updates from the language server. +/// +/// See ``SemanticTokenHighlightProvider`` for it's connection to the editor view. +final class SemanticTokenStorage: GenericSemanticTokenStorage { + /// Represents compressed semantic token data received from a language server. + struct CurrentState { + let resultId: String? + let tokenData: [UInt32] + let tokens: [SemanticToken] + } + + /// The last received result identifier. + var lastResultId: String? { + state?.resultId + } + + /// Indicates if the storage object has received any data. + /// Once `setData` has been called, this returns `true`. + /// Other operations will fail without any data in the storage object. + var hasReceivedData: Bool { + state != nil + } + + var state: CurrentState? + + /// Create an empty storage object. + init() { + state = nil + } + + // MARK: - Storage Conformance + + /// Finds all tokens in the given range. + /// - Parameter range: The range to query. + /// - Returns: All tokens found in the range. + func getTokensFor(range: LSPRange) -> [SemanticToken] { + guard let state = state, !state.tokens.isEmpty else { + return [] + } + var tokens: [SemanticToken] = [] + + // Perform a binary search + guard var idx = findLowerBound(in: range, data: state.tokens[...]) else { + return [] + } + + while idx < state.tokens.count && state.tokens[idx].startPosition < range.end { + tokens.append(state.tokens[idx]) + idx += 1 + } + + return tokens + } + + /// Clear the current state and set a new one. + /// - Parameter data: The semantic tokens to set as the current state. + func setData(_ data: borrowing SemanticTokens) { + state = CurrentState(resultId: data.resultId, tokenData: data.data, tokens: data.decode()) + } + + /// Apply a delta object from a language server and returns all token ranges that may need re-drawing. + /// + /// To calculate invalidated ranges: + /// - Grabs all semantic tokens that *will* be updated and invalidates their ranges + /// - Loops over all inserted tokens and invalidates their ranges + /// This may result in duplicated ranges. It's up to the caller to de-duplicate if necessary. See + /// ``SemanticTokenStorage/invalidatedRanges(startIdx:length:data:)``. + /// + /// - Parameter deltas: The deltas to apply. + /// - Returns: Ranges invalidated by the applied deltas. + func applyDelta(_ deltas: SemanticTokensDelta) -> [SemanticTokenRange] { + assert(state != nil, "State should be set before applying any deltas.") + guard var tokenData = state?.tokenData else { return [] } + var invalidatedSet: [SemanticTokenRange] = [] + + // Apply in reverse order (end to start) + for edit in deltas.edits.sorted(by: { $0.start > $1.start }) { + invalidatedSet.append( + contentsOf: invalidatedRanges(startIdx: edit.start, length: edit.deleteCount, data: tokenData[...]) + ) + + // Apply to our copy of the tokens array + if edit.deleteCount > 0 { + tokenData.replaceSubrange(Int(edit.start)..) -> [SemanticTokenRange] { + var ranges: [SemanticTokenRange] = [] + var idx = startIdx - (startIdx % 5) + while idx < startIdx + length { + ranges.append( + SemanticTokenRange( + line: data[Int(idx)], + char: data[Int(idx + 1)], + length: data[Int(idx + 2)] + ) + ) + idx += 5 + } + return ranges + } + + // MARK: - Binary Search + + /// Finds the lowest index of a `SemanticToken` that is entirely within the specified range. + /// - Complexity: Runs an **O(log n)** binary search on the data array. + /// - Parameters: + /// - range: The range to search in, *not* inclusive. + /// - data: The tokens to search. Takes an array slice to avoid unnecessary copying. This must be ordered by + /// `startPosition`. + /// - Returns: The index in the data array of the lowest data element that lies within the given range, or `nil` + /// if none are found. + func findLowerBound(in range: LSPRange, data: ArraySlice) -> Int? { + var low = 0 + var high = data.count + + // Find the first token with startPosition >= range.start. + while low < high { + let mid = low + (high - low) / 2 + if data[mid].startPosition < range.start { + low = mid + 1 + } else { + high = mid + } + } + + // Return the item at `low` if it's valid. + if low < data.count && data[low].startPosition >= range.start && data[low].endPosition < range.end { + return low + } + + return nil + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index 563604aa7e..be69c6647c 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -12,7 +12,7 @@ extension LanguageServer { /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. /// - Throws: Throws errors produced by the language server connection. - func openDocument(_ document: CodeFileDocument) async throws { + func openDocument(_ document: DocumentType) async throws { do { guard resolveOpenCloseSupport(), let content = await getIsolatedDocumentContent(document) else { return @@ -29,7 +29,7 @@ extension LanguageServer { ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) - await updateIsolatedDocument(document, coordinator: openFiles.contentCoordinator(for: document)) + await updateIsolatedDocument(document) } catch { logger.warning("addDocument: Error \(error)") throw error @@ -41,9 +41,12 @@ extension LanguageServer { /// - Throws: Throws errors produced by the language server connection. func closeDocument(_ uri: String) async throws { do { - guard resolveOpenCloseSupport() && openFiles.document(for: uri) != nil else { return } + guard resolveOpenCloseSupport(), let document = openFiles.document(for: uri) else { return } logger.debug("Closing document \(uri, privacy: .private)") + openFiles.removeDocument(for: uri) + await clearIsolatedDocument(document) + let params = DidCloseTextDocumentParams(textDocument: TextDocumentIdentifier(uri: uri)) try await lspInstance.textDocumentDidClose(params) } catch { @@ -78,10 +81,11 @@ extension LanguageServer { func documentChanged(uri: String, changes: [DocumentChange]) async throws { do { logger.debug("Document updated, \(uri, privacy: .private)") + guard let document = openFiles.document(for: uri) else { return } + switch resolveDocumentSyncKind() { case .full: - guard let document = openFiles.document(for: uri), - let content = await getIsolatedDocumentContent(document) else { + guard let content = await getIsolatedDocumentContent(document) else { return } let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content.string) @@ -100,6 +104,10 @@ extension LanguageServer { case .none: return } + + // Let the semantic token provider know about the update. + // Note for future: If a related LSP object need notifying about document changes, do it here. + try await document.languageServerObjects.highlightProvider?.documentDidChange() } catch { logger.warning("closeDocument: Error \(error)") throw error @@ -110,18 +118,25 @@ extension LanguageServer { /// Helper function for grabbing a document's content from the main actor. @MainActor - private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + private func getIsolatedDocumentContent(_ document: DocumentType) -> DocumentContent? { guard let uri = document.languageServerURI, - let language = document.getLanguage().lspLanguage, let content = document.content?.string else { return nil } - return DocumentContent(uri: uri, language: language, string: content) + return DocumentContent(uri: uri, language: document.getLanguage().id.rawValue, string: content) + } + + @MainActor + private func updateIsolatedDocument(_ document: DocumentType) { + document.languageServerObjects = LanguageServerDocumentObjects( + textCoordinator: openFiles.contentCoordinator(for: document), + highlightProvider: openFiles.semanticHighlighter(for: document) + ) } @MainActor - private func updateIsolatedDocument(_ document: CodeFileDocument, coordinator: LSPContentCoordinator?) { - document.lspCoordinator = coordinator + private func clearIsolatedDocument(_ document: DocumentType) { + document.languageServerObjects = LanguageServerDocumentObjects() } // swiftlint:disable line_length @@ -156,7 +171,7 @@ extension LanguageServer { // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` fileprivate struct DocumentContent { let uri: String - let language: LanguageIdentifier + let language: String let string: String } } diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift index 02cb29947d..b95098d02e 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift @@ -9,12 +9,9 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - /// Setup and test the validity of a rename operation at a given location func requestSemanticTokens(for documentURI: String) async throws -> SemanticTokensResponse { do { - let params = SemanticTokensParams( - textDocument: TextDocumentIdentifier(uri: documentURI) - ) + let params = SemanticTokensParams(textDocument: TextDocumentIdentifier(uri: documentURI)) return try await lspInstance.semanticTokensFull(params) } catch { logger.warning("requestSemanticTokens full: Error \(error)") @@ -22,19 +19,6 @@ extension LanguageServer { } } - func requestSemanticTokens( - for documentURI: String, - forRange range: LSPRange - ) async throws -> SemanticTokensResponse { - do { - let params = SemanticTokensRangeParams(textDocument: TextDocumentIdentifier(uri: documentURI), range: range) - return try await lspInstance.semanticTokensRange(params) - } catch { - logger.warning("requestSemanticTokens range: Error \(error)") - throw error - } - } - func requestSemanticTokens( for documentURI: String, previousResultId: String diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index eab8be5504..a7c48bb251 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -11,8 +11,11 @@ import LanguageClient import LanguageServerProtocol import OSLog -class LanguageServer { - static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") +/// A client for language servers. +class LanguageServer { + static var logger: Logger { // types with associated types cannot have constant static properties + Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") + } let logger: Logger /// Identifies which language the server belongs to @@ -25,7 +28,7 @@ class LanguageServer { /// Tracks documents and their associated objects. /// Use this property when adding new objects that need to track file data, or have a state associated with the /// language server and a document. For example, the content coordinator. - let openFiles: LanguageServerFileMap + let openFiles: LanguageServerFileMap /// Maps the language server's highlight config to one CodeEdit can read. See ``SemanticTokenMap``. let highlightMap: SemanticTokenMap? @@ -152,13 +155,13 @@ class LanguageServer { // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokensClientCapabilities semanticTokens: SemanticTokensClientCapabilities( dynamicRegistration: false, - requests: .init(range: true, delta: true), + requests: .init(range: false, delta: true), tokenTypes: SemanticTokenTypes.allStrings, tokenModifiers: SemanticTokenModifiers.allStrings, formats: [.relative], overlappingTokenSupport: true, multilineTokenSupport: true, - serverCancelSupport: true, + serverCancelSupport: false, augmentsSyntaxTokens: true ) ) @@ -218,7 +221,7 @@ class LanguageServer { processId: nil, locale: nil, rootPath: nil, - rootUri: workspacePath, + rootUri: "file://" + workspacePath, // Make it a URI initializationOptions: [], capabilities: capabilities, trace: nil, diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift index c681e894a6..fd71a06b7a 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -9,39 +9,55 @@ import Foundation import LanguageServerProtocol /// Tracks data associated with files and language servers. -class LanguageServerFileMap { +class LanguageServerFileMap { + typealias HighlightProviderType = SemanticTokenHighlightProvider + /// Extend this struct as more objects are associated with a code document. private struct DocumentObject { let uri: String var documentVersion: Int - var contentCoordinator: LSPContentCoordinator + var contentCoordinator: LSPContentCoordinator + var semanticHighlighter: HighlightProviderType? } - private var trackedDocuments: NSMapTable + private var trackedDocuments: NSMapTable private var trackedDocumentData: [String: DocumentObject] = [:] init() { - trackedDocuments = NSMapTable(valueOptions: [.weakMemory]) + trackedDocuments = NSMapTable(valueOptions: [.weakMemory]) } // MARK: - Track & Remove Documents - func addDocument(_ document: CodeFileDocument, for server: LanguageServer) { + func addDocument(_ document: DocumentType, for server: LanguageServer) { guard let uri = document.languageServerURI else { return } trackedDocuments.setObject(document, forKey: uri as NSString) - trackedDocumentData[uri] = DocumentObject( + var docData = DocumentObject( uri: uri, documentVersion: 0, - contentCoordinator: LSPContentCoordinator(documentURI: uri, languageServer: server) + contentCoordinator: LSPContentCoordinator( + documentURI: uri, + languageServer: server + ), + semanticHighlighter: nil ) + + if let tokenMap = server.highlightMap { + docData.semanticHighlighter = HighlightProviderType( + tokenMap: tokenMap, + languageServer: server, + documentURI: uri + ) + } + + trackedDocumentData[uri] = docData } - func document(for uri: DocumentUri) -> CodeFileDocument? { - let url = URL(filePath: uri) - return trackedDocuments.object(forKey: url.absolutePath as NSString) + func document(for uri: DocumentUri) -> DocumentType? { + return trackedDocuments.object(forKey: uri as NSString) } - func removeDocument(for document: CodeFileDocument) { + func removeDocument(for document: DocumentType) { guard let uri = document.languageServerURI else { return } removeDocument(for: uri) } @@ -53,7 +69,7 @@ class LanguageServerFileMap { // MARK: - Version Number Tracking - func incrementVersion(for document: CodeFileDocument) -> Int { + func incrementVersion(for document: DocumentType) -> Int { guard let uri = document.languageServerURI else { return 0 } return incrementVersion(for: uri) } @@ -63,7 +79,7 @@ class LanguageServerFileMap { return trackedDocumentData[uri]?.documentVersion ?? 0 } - func documentVersion(for document: CodeFileDocument) -> Int? { + func documentVersion(for document: DocumentType) -> Int? { guard let uri = document.languageServerURI else { return nil } return documentVersion(for: uri) } @@ -74,12 +90,19 @@ class LanguageServerFileMap { // MARK: - Content Coordinator - func contentCoordinator(for document: CodeFileDocument) -> LSPContentCoordinator? { + func contentCoordinator(for document: DocumentType) -> LSPContentCoordinator? { guard let uri = document.languageServerURI else { return nil } return contentCoordinator(for: uri) } - func contentCoordinator(for uri: DocumentUri) -> LSPContentCoordinator? { + func contentCoordinator(for uri: DocumentUri) -> LSPContentCoordinator? { trackedDocumentData[uri]?.contentCoordinator } + + // MARK: - Semantic Highlighter + + func semanticHighlighter(for document: DocumentType) -> HighlightProviderType? { + guard let uri = document.languageServerURI else { return nil } + return trackedDocumentData[uri]?.semanticHighlighter + } } diff --git a/CodeEdit/Features/LSP/LanguageServerDocument.swift b/CodeEdit/Features/LSP/LanguageServerDocument.swift new file mode 100644 index 0000000000..8b4b09a47d --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServerDocument.swift @@ -0,0 +1,23 @@ +// +// LanguageServerDocument.swift +// CodeEdit +// +// Created by Khan Winter on 2/12/25. +// + +import AppKit +import CodeEditLanguages + +/// A set of properties a language server sets when a document is registered. +struct LanguageServerDocumentObjects { + var textCoordinator: LSPContentCoordinator? + var highlightProvider: SemanticTokenHighlightProvider? +} + +/// A protocol that allows a language server to register objects on a text document. +protocol LanguageServerDocument: AnyObject { + var content: NSTextStorage? { get } + var languageServerURI: String? { get } + var languageServerObjects: LanguageServerDocumentObjects { get set } + func getLanguage() -> CodeLanguage +} diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 5110c6c3e1..df74fb1399 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -42,7 +42,7 @@ import CodeEditLanguages /// do { /// guard var languageClient = self.languageClient(for: .python) else { /// print("Failed to get client") -/// throw ServerManagerError.languageClientNotFound +/// throw LSPServiceError.languageClientNotFound /// } /// /// let testFilePathStr = "" @@ -54,7 +54,7 @@ import CodeEditLanguages /// // Completion example /// let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 /// let completions = try await languageClient.requestCompletion( -/// document: testFileURL.absoluteString, +/// document: testFileURL.lspURI, /// position: textPosition /// ) /// switch completions { @@ -99,6 +99,8 @@ import CodeEditLanguages /// ``` @MainActor final class LSPService: ObservableObject { + typealias LanguageServerType = LanguageServer + let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") struct ClientKey: Hashable, Equatable { @@ -112,7 +114,7 @@ final class LSPService: ObservableObject { } /// Holds the active language clients - var languageClients: [ClientKey: LanguageServer] = [:] + var languageClients: [ClientKey: LanguageServerType] = [:] /// Holds the language server configurations for all the installed language servers var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] /// Holds all the event listeners for each active language client @@ -162,10 +164,16 @@ final class LSPService: ObservableObject { } /// Gets the language client for the specified language - func languageClient(for languageId: LanguageIdentifier, workspacePath: String) -> LanguageServer? { + func languageClient(for languageId: LanguageIdentifier, workspacePath: String) -> LanguageServerType? { return languageClients[ClientKey(languageId, workspacePath)] } + func languageClient(forDocument url: URL) -> LanguageServerType? { + languageClients.values.first(where: { $0.openFiles.document(for: url.lspURI) != nil }) + } + + // MARK: - Start Server + /// Given a language and workspace path, will attempt to start the language server /// - Parameters: /// - languageId: The ID of the language server to start. @@ -174,14 +182,14 @@ final class LSPService: ObservableObject { func startServer( for languageId: LanguageIdentifier, workspacePath: String - ) async throws -> LanguageServer { + ) async throws -> LanguageServerType { guard let serverBinary = languageConfigs[languageId] else { logger.error("Couldn't find language sever binary for \(languageId.rawValue)") throw LSPError.binaryNotFound } logger.info("Starting \(languageId.rawValue) language server") - let server = try await LanguageServer.createServer( + let server = try await LanguageServerType.createServer( for: languageId, with: serverBinary, workspacePath: workspacePath @@ -193,6 +201,8 @@ final class LSPService: ObservableObject { return server } + // MARK: - Document Management + /// Notify all relevant language clients that a document was opened. /// - Note: Must be invoked after the contents of the file are available. /// - Parameter document: The code document that was opened. @@ -203,7 +213,7 @@ final class LSPService: ObservableObject { return } Task { - let languageServer: LanguageServer + let languageServer: LanguageServerType do { if let server = self.languageClients[ClientKey(lspLanguage, workspacePath)] { languageServer = server @@ -228,21 +238,19 @@ final class LSPService: ObservableObject { /// Notify all relevant language clients that a document was closed. /// - Parameter url: The url of the document that was closed func closeDocument(_ url: URL) { - guard let languageClient = languageClients.first(where: { - $0.value.openFiles.document(for: url.absolutePath) != nil - })?.value else { - return - } + guard let languageClient = languageClient(forDocument: url) else { return } Task { do { - try await languageClient.closeDocument(url.absolutePath) + try await languageClient.closeDocument(url.lspURI) } catch { // swiftlint:disable:next line_length - logger.error("Failed to close document: \(url.absolutePath, privacy: .private), language: \(languageClient.languageId.rawValue). Error \(error)") + logger.error("Failed to close document: \(url.lspURI, privacy: .private), language: \(languageClient.languageId.rawValue). Error \(error)") } } } + // MARK: - Close Workspace + /// Close all language clients for a workspace. /// /// This is intentionally synchronous so we can exit from the workspace document's ``WorkspaceDocument/close()`` @@ -266,6 +274,8 @@ final class LSPService: ObservableObject { } } + // MARK: - Stop Servers + /// Attempts to stop a running language server. Throws an error if the server is not found /// or if the language server throws an error while trying to shutdown. /// - Parameters: @@ -274,7 +284,7 @@ final class LSPService: ObservableObject { func stopServer(forLanguage languageId: LanguageIdentifier, workspacePath: String) async throws { guard let server = server(for: languageId, workspacePath: workspacePath) else { logger.error("Server not found for language \(languageId.rawValue) during stop operation") - throw ServerManagerError.serverNotFound + throw LSPServiceError.serverNotFound } do { try await server.shutdownAndExit() @@ -309,12 +319,3 @@ final class LSPService: ObservableObject { eventListeningTasks.removeAll() } } - -// MARK: - Errors - -enum ServerManagerError: Error { - case serverNotFound - case serverStartFailed - case serverStopFailed - case languageClientNotFound -} diff --git a/CodeEdit/Features/LSP/Service/LSPServiceError.swift b/CodeEdit/Features/LSP/Service/LSPServiceError.swift new file mode 100644 index 0000000000..d542e4d755 --- /dev/null +++ b/CodeEdit/Features/LSP/Service/LSPServiceError.swift @@ -0,0 +1,13 @@ +// +// LSPServiceError.swift +// CodeEdit +// +// Created by Khan Winter on 3/24/25. +// + +enum LSPServiceError: Error { + case serverNotFound + case serverStartFailed + case serverStopFailed + case languageClientNotFound +} diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift index a8971c8df7..dfec82a1b0 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift +++ b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorResultList/FindNavigatorListViewController.swift @@ -209,26 +209,69 @@ extension FindNavigatorListViewController: NSOutlineViewDelegate { } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { - if let item = item as? SearchResultMatchModel { - let tempView = NSTextField(wrappingLabelWithString: item.attributedLabel().string) - tempView.allowsDefaultTighteningForTruncation = false - tempView.cell?.truncatesLastVisibleLine = true + if let matchItem = item as? SearchResultMatchModel { + guard let column = outlineView.tableColumns.first else { + return rowHeight + } + let columnWidth = column.width + let indentationLevel = outlineView.level(forItem: item) + let indentationSpace = CGFloat(indentationLevel) * outlineView.indentationPerLevel + let horizontalPaddingAndFixedElements: CGFloat = 24.0 + + let availableWidth = columnWidth - indentationSpace - horizontalPaddingAndFixedElements + + guard availableWidth > 0 else { + // Not enough space to display anything, return minimum height + return max(rowHeight, Settings.shared.preferences.general.projectNavigatorSize.rowHeight) + } + + let attributedString = matchItem.attributedLabel() + + let tempView = NSTextField() + tempView.allowsEditingTextAttributes = true + tempView.attributedStringValue = attributedString + + tempView.isEditable = false + tempView.isBordered = false + tempView.drawsBackground = false + tempView.alignment = .natural + tempView.cell?.wraps = true - tempView.maximumNumberOfLines = 3 - tempView.attributedStringValue = item.attributedLabel() - tempView.layout() - let width = outlineView.frame.width - outlineView.indentationPerLevel*2 - 24 - return tempView.sizeThatFits( - NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) - ).height + 8 - } else { - return rowHeight + tempView.cell?.usesSingleLineMode = false + tempView.lineBreakMode = .byWordWrapping + tempView.maximumNumberOfLines = Settings.shared.preferences.general.findNavigatorDetail.rawValue + tempView.preferredMaxLayoutWidth = availableWidth + + var calculatedHeight = tempView.sizeThatFits( + NSSize(width: availableWidth, height: .greatestFiniteMagnitude) + ).height + + // Total vertical padding (top + bottom) within the cell around the text + let verticalPaddingInCell: CGFloat = 8.0 + calculatedHeight += verticalPaddingInCell + return max(calculatedHeight, self.rowHeight) } + // For parent items + return prefs.general.projectNavigatorSize.rowHeight } func outlineViewColumnDidResize(_ notification: Notification) { - let indexes = IndexSet(integersIn: 0..( + get: { Double(textEditing.reformatAtColumn) }, + set: { textEditing.reformatAtColumn = Int($0) } + ), + in: 40...200, + step: 1, + format: .number + ) + .help("The column at which text should be reformatted") + + Toggle("Show Reformatting Guide", isOn: $textEditing.showReformattingGuide) + .help("Shows a vertical guide at the reformat column") + } } diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index d7e7ef766e..a9e501d200 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -184,11 +184,11 @@ struct SettingsView: View { } } .navigationSplitViewColumnWidth(500) - .hideSidebarToggle() .onAppear { model.backButtonVisible = false } } + .hideSidebarToggle() .navigationTitle(selectedPage.name.rawValue) .toolbar { ToolbarItem(placement: .navigation) { diff --git a/CodeEdit/Features/Settings/Views/SettingsForm.swift b/CodeEdit/Features/Settings/Views/SettingsForm.swift index 14faaaa615..ee093864a9 100644 --- a/CodeEdit/Features/Settings/Views/SettingsForm.swift +++ b/CodeEdit/Features/Settings/Views/SettingsForm.swift @@ -17,39 +17,41 @@ struct SettingsForm: View { @ViewBuilder var content: Content var body: some View { - Form { - Section { - EmptyView() - } footer: { - Rectangle() - .frame(height: 0) - .background( - GeometryReader { - Color.clear.preference( - key: ViewOffsetKey.self, - value: -$0.frame(in: .named("scroll")).origin.y - ) - } - ) - .onPreferenceChange(ViewOffsetKey.self) { - if $0 <= -20.0 && !model.scrolledToTop { - withAnimation { - model.scrolledToTop = true + NavigationStack { + Form { + Section { + EmptyView() + } footer: { + Rectangle() + .frame(height: 0) + .background( + GeometryReader { + Color.clear.preference( + key: ViewOffsetKey.self, + value: -$0.frame(in: .named("scroll")).origin.y + ) } - } else if $0 > -20.0 && model.scrolledToTop { - withAnimation { - model.scrolledToTop = false + ) + .onPreferenceChange(ViewOffsetKey.self) { + if $0 <= -20.0 && !model.scrolledToTop { + withAnimation { + model.scrolledToTop = true + } + } else if $0 > -20.0 && model.scrolledToTop { + withAnimation { + model.scrolledToTop = false + } } } - } + } + content } - content - } - .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15)) { - $0.scrollerInsets.top = 50 + .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15)) { + $0.scrollerInsets.top = 50 + } + .formStyle(.grouped) + .coordinateSpace(name: "scroll") } - .formStyle(.grouped) - .coordinateSpace(name: "scroll") .safeAreaInset(edge: .top, spacing: -50) { EffectView(.menu) .opacity(!model.scrolledToTop ? 1 : 0) diff --git a/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift b/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift index 09b65c858b..b48c81d303 100644 --- a/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift +++ b/CodeEdit/Features/Settings/Views/View+HideSidebarToggle.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SwiftUIIntrospect extension View { func hideSidebarToggle() -> some View { @@ -16,12 +17,11 @@ extension View { struct HideSidebarToggleViewModifier: ViewModifier { func body(content: Content) -> some View { content - .task { - let window = NSApp.windows.first { $0.identifier?.rawValue == SceneID.settings.rawValue }! - let sidebaritem = "com.apple.SwiftUI.navigationSplitView.toggleSidebar" - let index = window.toolbar?.items.firstIndex { $0.itemIdentifier.rawValue == sidebaritem } - if let index { - window.toolbar?.removeItem(at: index) + .introspect(.window, on: .macOS(.v13, .v14, .v15)) { window in + if let toolbar = window.toolbar { + let sidebarItem = "com.apple.SwiftUI.navigationSplitView.toggleSidebar" + let sidebarToggle = toolbar.items.first(where: { $0.itemIdentifier.rawValue == sidebarItem }) + sidebarToggle?.view?.isHidden = true } } } diff --git a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift index 325a0b123e..a9ab0ea9f4 100644 --- a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift +++ b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift @@ -17,7 +17,6 @@ struct NavigationBarBackButtonVisible: ViewModifier { .toolbar { ToolbarItem(placement: .navigation) { Button { - print(self.presentationMode.wrappedValue) self.presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "chevron.left") @@ -25,10 +24,13 @@ struct NavigationBarBackButtonVisible: ViewModifier { } } } - .hideSidebarToggle() + .navigationBarBackButtonHidden() .onAppear { model.backButtonVisible = true } + .onDisappear { + model.backButtonVisible = false + } } } diff --git a/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCloneViewModel.swift b/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCloneViewModel.swift index 4c1dd789da..4069a58266 100644 --- a/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCloneViewModel.swift +++ b/CodeEdit/Features/SourceControl/Clone/ViewModels/GitCloneViewModel.swift @@ -31,6 +31,22 @@ class GitCloneViewModel: ObservableObject { } return false } + /// Check if Git is installed + /// - Returns: True if Git is found by running "which git" command + func isGitInstalled() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["git"] + let pipe = Pipe() + process.standardOutput = pipe + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } /// Check if clipboard contains git url func checkClipboard() { @@ -43,6 +59,13 @@ class GitCloneViewModel: ObservableObject { /// Clone repository func cloneRepository(completionHandler: @escaping (URL) -> Void) { + if !isGitInstalled() { + showAlert( + alertMsg: "Git installation not found.", + infoText: "Ensure Git is installed on your system and try again." + ) + return + } if repoUrlStr == "" { showAlert( alertMsg: "Url cannot be empty", diff --git a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift b/CodeEdit/Features/SplitView/Model/SplitViewItem.swift index 4229bf5e4f..9f8521e808 100644 --- a/CodeEdit/Features/SplitView/Model/SplitViewItem.swift +++ b/CodeEdit/Features/SplitView/Model/SplitViewItem.swift @@ -45,9 +45,15 @@ class SplitViewItem: ObservableObject { /// - Parameter child: the view corresponding to the SplitViewItem. func update(child: _VariadicView.Children.Element) { self.item.canCollapse = child[SplitViewItemCanCollapseViewTraitKey.self] + let canAnimate = child[SplitViewItemCanAnimateViewTraitKey.self] DispatchQueue.main.async { self.observers = [] - self.item.animator().isCollapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue + let collapsed = child[SplitViewItemCollapsedViewTraitKey.self].wrappedValue + if canAnimate { + self.item.animator().isCollapsed = collapsed + } else { + self.item.isCollapsed = collapsed + } self.item.holdingPriority = child[SplitViewHoldingPriorityTraitKey.self] self.observers = self.createObservers() } diff --git a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift b/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift index 3df0c7828b..95f4e01bb1 100644 --- a/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift +++ b/CodeEdit/Features/SplitView/Views/SplitViewModifiers.swift @@ -23,6 +23,10 @@ struct SplitViewHoldingPriorityTraitKey: _ViewTraitKey { static var defaultValue: NSLayoutConstraint.Priority = .defaultLow } +struct SplitViewItemCanAnimateViewTraitKey: _ViewTraitKey { + static var defaultValue: Bool { true } +} + extension View { func collapsed(_ value: Binding) -> some View { self @@ -43,4 +47,8 @@ extension View { self ._trait(SplitViewHoldingPriorityTraitKey.self, priority) } + + func splitViewCanAnimate(_ enabled: Binding) -> some View { + self._trait(SplitViewItemCanAnimateViewTraitKey.self, enabled.wrappedValue) + } } diff --git a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift index cd6fdf2b30..0cc075464a 100644 --- a/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift +++ b/CodeEdit/Features/UtilityArea/ViewModels/UtilityAreaViewModel.swift @@ -21,6 +21,9 @@ class UtilityAreaViewModel: ObservableObject { /// Indicates whether debugger is collapse or not @Published var isCollapsed: Bool = false + /// Indicates whether collapse animation should be enabled when utility area is toggled + @Published var animateCollapse: Bool = true + /// Returns true when the drawer is visible @Published var isMaximized: Bool = false @@ -47,7 +50,8 @@ class UtilityAreaViewModel: ObservableObject { workspace.addToWorkspaceState(key: .utilityAreaMaximized, value: isMaximized) } - func togglePanel() { + func togglePanel(animation: Bool = true) { + self.animateCollapse = animation self.isMaximized = false self.isCollapsed.toggle() } diff --git a/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift b/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift index 4f03ef8500..53fdaa641a 100644 --- a/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift +++ b/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift @@ -7,6 +7,7 @@ import AppKit import CoreSpotlight +import OSLog /// Helper methods for managing the recent projects list and donating list items to CoreSpotlight. /// @@ -14,32 +15,58 @@ import CoreSpotlight /// /// If a UI element needs to listen to changes in this list, listen for the /// ``RecentProjectsStore/didUpdateNotification`` notification. -enum RecentProjectsStore { - private static let defaultsKey = "recentProjectPaths" +class RecentProjectsStore { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RecentProjectsStore") + + /// The default projects store, uses the `UserDefaults.standard` storage location. + static let shared = RecentProjectsStore() + + private static let projectsdDefaultsKey = "recentProjectPaths" static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate") - static func recentProjectPaths() -> [String] { - UserDefaults.standard.array(forKey: defaultsKey) as? [String] ?? [] + /// The storage location for recent projects + let defaults: UserDefaults + + #if DEBUG + /// Create a new store with a `UserDefaults` storage location. + init(defaults: UserDefaults = UserDefaults.standard) { + self.defaults = defaults + } + #else + /// Create a new store with a `UserDefaults` storage location. + private init(defaults: UserDefaults = UserDefaults.standard) { + self.defaults = defaults } + #endif - static func recentProjectURLs() -> [URL] { - recentProjectPaths().map { URL(filePath: $0) } + /// Gets the recent paths array from `UserDefaults`. + private func recentPaths() -> [String] { + defaults.array(forKey: Self.projectsdDefaultsKey) as? [String] ?? [] } - private static func setPaths(_ paths: [String]) { - var paths = paths - // Remove duplicates - var foundPaths = Set() - for (idx, path) in paths.enumerated().reversed() { - if foundPaths.contains(path) { - paths.remove(at: idx) - } else { - foundPaths.insert(path) - } - } + /// Gets all recent paths from `UserDefaults` as an array of `URL`s. Includes both **projects** and + /// **single files**. + /// To filter for either projects or single files, use ``recentProjectURLs()`` or ``recentFileURLs``, respectively. + func recentURLs() -> [URL] { + recentPaths().map { URL(filePath: $0) } + } - // Limit list to to 100 items after de-duplication - UserDefaults.standard.setValue(Array(paths.prefix(100)), forKey: defaultsKey) + /// Gets the recent **Project** `URL`s from `UserDefaults`. + /// To get both single files and projects, use ``recentURLs()``. + func recentProjectURLs() -> [URL] { + recentURLs().filter { $0.isFolder } + } + + /// Gets the recent **Single File** `URL`s from `UserDefaults`. + /// To get both single files and projects, use ``recentURLs()``. + func recentFileURLs() -> [URL] { + recentURLs().filter { !$0.isFolder } + } + + /// Save a new paths array to defaults. Automatically limits the list to the most recent `100` items, donates + /// search items to Spotlight, and notifies observers. + private func setPaths(_ paths: [String]) { + defaults.setValue(Array(paths.prefix(100)), forKey: Self.projectsdDefaultsKey) setDocumentControllerRecents() donateSearchableItems() NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil) @@ -49,41 +76,45 @@ enum RecentProjectsStore { /// Moves the path to the front if it was in the list already, or prepends it. /// Saves the list to defaults when called. /// - Parameter url: The url that was opened. Any url is accepted. File, directory, https. - static func documentOpened(at url: URL) { - var paths = recentProjectURLs() - if let containedIndex = paths.firstIndex(where: { $0.componentCompare(url) }) { - paths.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0) + func documentOpened(at url: URL) { + var projectURLs = recentURLs() + + if let containedIndex = projectURLs.firstIndex(where: { $0.componentCompare(url) }) { + projectURLs.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0) } else { - paths.insert(url, at: 0) + projectURLs.insert(url, at: 0) } - setPaths(paths.map { $0.path(percentEncoded: false) }) + + setPaths(projectURLs.map { $0.path(percentEncoded: false) }) } - /// Remove all paths in the set. + /// Remove all project paths in the set. /// - Parameter paths: The paths to remove. /// - Returns: The remaining urls in the recent projects list. - static func removeRecentProjects(_ paths: Set) -> [URL] { - var recentProjectPaths = recentProjectURLs() + func removeRecentProjects(_ paths: Set) -> [URL] { + let paths = Set(paths.map { $0.path(percentEncoded: false) }) + var recentProjectPaths = recentPaths() recentProjectPaths.removeAll(where: { paths.contains($0) }) - setPaths(recentProjectPaths.map { $0.path(percentEncoded: false) }) - return recentProjectURLs() + setPaths(recentProjectPaths) + return recentURLs() } - static func clearList() { + func clearList() { setPaths([]) + NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil) } /// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date. - private static func setDocumentControllerRecents() { + private func setDocumentControllerRecents() { CodeEditDocumentController.shared.clearRecentDocuments(nil) - for path in recentProjectURLs().prefix(10) { + for path in recentURLs().prefix(10) { CodeEditDocumentController.shared.noteNewRecentDocumentURL(path) } } /// Donates all recent URLs to Core Search, making them searchable in Spotlight - private static func donateSearchableItems() { - let searchableItems = recentProjectURLs().map { entity in + private func donateSearchableItems() { + let searchableItems = recentURLs().map { entity in let attributeSet = CSSearchableItemAttributeSet(contentType: .content) attributeSet.title = entity.lastPathComponent attributeSet.relatedUniqueIdentifier = entity.path() @@ -93,9 +124,9 @@ enum RecentProjectsStore { attributeSet: attributeSet ) } - CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in + CSSearchableIndex.default().indexSearchableItems(searchableItems) { [weak self] error in if let error = error { - print(error) + self?.logger.debug("Failed to donate recent projects, error: \(error, privacy: .auto)") } } } diff --git a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift b/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift index 381b615713..f39c14327c 100644 --- a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift +++ b/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift @@ -19,8 +19,8 @@ struct RecentProjectsListView: View { init(openDocument: @escaping (URL?, @escaping () -> Void) -> Void, dismissWindow: @escaping () -> Void) { self.openDocument = openDocument self.dismissWindow = dismissWindow - self._recentProjects = .init(initialValue: RecentProjectsStore.recentProjectURLs()) - self._selection = .init(initialValue: Set(RecentProjectsStore.recentProjectURLs().prefix(1))) + self._recentProjects = .init(initialValue: RecentProjectsStore.shared.recentURLs()) + self._selection = .init(initialValue: Set(RecentProjectsStore.shared.recentURLs().prefix(1))) } var listEmptyView: some View { @@ -81,16 +81,20 @@ struct RecentProjectsListView: View { } } } - .onReceive(NotificationCenter.default.publisher(for: RecentProjectsStore.didUpdateNotification)) { _ in + .onReceive( + NotificationCenter + .default + .publisher(for: RecentProjectsStore.didUpdateNotification).receive(on: RunLoop.main) + ) { _ in updateRecentProjects() } } func removeRecentProjects() { - recentProjects = RecentProjectsStore.removeRecentProjects(selection) + recentProjects = RecentProjectsStore.shared.removeRecentProjects(selection) } func updateRecentProjects() { - recentProjects = RecentProjectsStore.recentProjectURLs() + recentProjects = RecentProjectsStore.shared.recentURLs() } } diff --git a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift index 483a6baa21..f4c3c3d337 100644 --- a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift +++ b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift @@ -8,27 +8,48 @@ import AppKit class RecentProjectsMenu: NSObject { + let projectsStore: RecentProjectsStore + + init(projectsStore: RecentProjectsStore = .shared) { + self.projectsStore = projectsStore + } + func makeMenu() -> NSMenu { let menu = NSMenu(title: NSLocalizedString("Open Recent", comment: "Open Recent menu title")) - let paths = RecentProjectsStore.recentProjectURLs().prefix(10) + addFileURLs(to: menu, fileURLs: projectsStore.recentProjectURLs().prefix(10)) + menu.addItem(NSMenuItem.separator()) + addFileURLs(to: menu, fileURLs: projectsStore.recentFileURLs().prefix(10)) + menu.addItem(NSMenuItem.separator()) + + let clearMenuItem = NSMenuItem( + title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"), + action: #selector(clearMenuItemClicked(_:)), + keyEquivalent: "" + ) + clearMenuItem.target = self + menu.addItem(clearMenuItem) + + return menu + } - for projectPath in paths { - let icon = NSWorkspace.shared.icon(forFile: projectPath.path()) + private func addFileURLs(to menu: NSMenu, fileURLs: ArraySlice) { + for url in fileURLs { + let icon = NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false)) icon.size = NSSize(width: 16, height: 16) - let alternateTitle = alternateTitle(for: projectPath) + let alternateTitle = alternateTitle(for: url) let primaryItem = NSMenuItem( - title: projectPath.lastPathComponent, + title: url.lastPathComponent, action: #selector(recentProjectItemClicked(_:)), keyEquivalent: "" ) primaryItem.target = self primaryItem.image = icon - primaryItem.representedObject = projectPath + primaryItem.representedObject = url - let containsDuplicate = paths.contains { url in - url != projectPath && url.lastPathComponent == projectPath.lastPathComponent + let containsDuplicate = fileURLs.contains { otherURL in + url != otherURL && url.lastPathComponent == otherURL.lastPathComponent } // If there's a duplicate, add the path. @@ -44,25 +65,13 @@ class RecentProjectsMenu: NSObject { alternateItem.attributedTitle = alternateTitle alternateItem.target = self alternateItem.image = icon - alternateItem.representedObject = projectPath + alternateItem.representedObject = url alternateItem.isAlternate = true alternateItem.keyEquivalentModifierMask = [.option] menu.addItem(primaryItem) menu.addItem(alternateItem) } - - menu.addItem(NSMenuItem.separator()) - - let clearMenuItem = NSMenuItem( - title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"), - action: #selector(clearMenuItemClicked(_:)), - keyEquivalent: "" - ) - clearMenuItem.target = self - menu.addItem(clearMenuItem) - - return menu } private func alternateTitle(for projectPath: URL) -> NSAttributedString { @@ -94,6 +103,6 @@ class RecentProjectsMenu: NSObject { @objc func clearMenuItemClicked(_ sender: NSMenuItem) { - RecentProjectsStore.clearList() + projectsStore.clearList() } } diff --git a/CodeEdit/Features/WindowCommands/ViewCommands.swift b/CodeEdit/Features/WindowCommands/ViewCommands.swift index fc6b8b72d8..69854c9d5f 100644 --- a/CodeEdit/Features/WindowCommands/ViewCommands.swift +++ b/CodeEdit/Features/WindowCommands/ViewCommands.swift @@ -111,6 +111,10 @@ extension ViewCommands { windowController?.toolbarCollapsed ?? true } + var isInterfaceHidden: Bool { + return windowController?.isInterfaceStillHidden() ?? false + } + var body: some View { Button("\(navigatorCollapsed ? "Show" : "Hide") Navigator") { windowController?.toggleFirstPanel() @@ -135,6 +139,12 @@ extension ViewCommands { } .disabled(windowController == nil) .keyboardShortcut("t", modifiers: [.option, .command]) + + Button("\(isInterfaceHidden ? "Show" : "Hide") Interface") { + windowController?.toggleInterface(shouldHide: !isInterfaceHidden) + } + .disabled(windowController == nil) + .keyboardShortcut(".", modifiers: .command) } } } diff --git a/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift b/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift new file mode 100644 index 0000000000..0e700938a7 --- /dev/null +++ b/CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift @@ -0,0 +1,18 @@ +// +// SemanticToken+Position.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import LanguageServerProtocol + +extension SemanticToken { + var startPosition: Position { + Position(line: Int(line), character: Int(char)) + } + + var endPosition: Position { + Position(line: Int(line), character: Int(char + length)) + } +} diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift b/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift index f41060423e..976f9970ff 100644 --- a/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift +++ b/CodeEdit/Utils/Extensions/TextView/TextView+SemanticTokenRangeProvider.swift @@ -7,8 +7,13 @@ import Foundation import CodeEditTextView +import LanguageServerProtocol extension TextView: SemanticTokenMapRangeProvider { + func nsRangeFrom(_ range: SemanticTokenRange) -> NSRange? { + nsRangeFrom(line: range.line, char: range.char, length: range.length) + } + func nsRangeFrom(line: UInt32, char: UInt32, length: UInt32) -> NSRange? { guard let line = layoutManager.textLineForIndex(Int(line)) else { return nil diff --git a/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift b/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift new file mode 100644 index 0000000000..f29f9057c7 --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift @@ -0,0 +1,18 @@ +// +// URL+LSPURI.swift +// CodeEdit +// +// Created by Khan Winter on 3/24/25. +// + +import Foundation + +extension URL { + /// A stable string to use when identifying documents with language servers. + /// Needs to be a valid URI, so always returns with the `file://` prefix to indicate it's a file URI. + /// + /// Use this whenever possible when using USLs in LSP processing if not using the ``LanguageServerDocument`` type. + var lspURI: String { + return "file://" + absolutePath + } +} diff --git a/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift b/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift index 3d411e9140..c0d3520986 100644 --- a/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift +++ b/CodeEdit/Utils/Extensions/URL/URL+componentCompare.swift @@ -15,4 +15,39 @@ extension URL { func componentCompare(_ other: URL) -> Bool { return self.pathComponents == other.pathComponents } + + /// Determines if another URL is lower in the file system than this URL. + /// + /// Examples: + /// ``` + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/file.txt")) // true + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/")) // false + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/")) // false + /// URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/Folder")) // true + /// ``` + /// + /// - Parameter other: The URL to compare. + /// - Returns: True, if the other URL is lower in the file system. + func containsSubPath(_ other: URL) -> Bool { + other.absoluteString.starts(with: absoluteString) + && other.pathComponents.count > pathComponents.count + } + + /// Compares this url with another, counting the number of shared path components. Stops counting once a + /// different component is found. + /// + /// - Note: URL treats a leading `/` as a component, so `/Users` and `/` will return `1`. + /// - Parameter other: The URL to compare against. + /// - Returns: The number of shared components. + func sharedComponents(_ other: URL) -> Int { + var count = 0 + for (component, otherComponent) in zip(pathComponents, other.pathComponents) { + if component == otherComponent { + count += 1 + } else { + return count + } + } + return count + } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 69b957bcdc..d9e2aa1b0e 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -68,6 +68,7 @@ struct WorkspaceView: View { Rectangle() .collapsable() .collapsed($utilityAreaViewModel.isCollapsed) + .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) .opacity(0) .frame(idealHeight: 260) .frame(minHeight: 100) diff --git a/CodeEditTestPlan.xctestplan b/CodeEditTestPlan.xctestplan index a8dbd06acc..66a20ae7ed 100644 --- a/CodeEditTestPlan.xctestplan +++ b/CodeEditTestPlan.xctestplan @@ -9,7 +9,6 @@ } ], "defaultOptions" : { - "codeCoverage" : false, "targetForVariableExpansion" : { "containerPath" : "container:CodeEdit.xcodeproj", "identifier" : "B658FB2B27DA9E0F00EA4DBD", diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift similarity index 80% rename from CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift rename to CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift index d5bee0c139..236f2a7215 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+CodeFileDocument.swift @@ -1,5 +1,5 @@ // -// LanguageServer+DocumentTests.swift +// LanguageServer+CodeFileDocument.swift // CodeEditTests // // Created by Khan Winter on 9/9/24. @@ -13,10 +13,16 @@ import LanguageServerProtocol @testable import CodeEdit -final class LanguageServerDocumentTests: XCTestCase { +/// This is an integration test for notifications relating to the ``CodeFileDocument`` class. +/// +/// For *unit* tests with the language server class, add tests to the `LanguageServer+DocumentObjects` test class as +/// it's cleaner and makes correct use of the mock document type. +final class LanguageServerCodeFileDocumentTests: XCTestCase { // Test opening documents in CodeEdit triggers creating a language server, // further opened documents don't create new servers + typealias LanguageServerType = LanguageServer + var tempTestDir: URL! override func setUp() { @@ -44,7 +50,7 @@ final class LanguageServerDocumentTests: XCTestCase { } } - func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServer) { + func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServerType) { let bufferingConnection = BufferingServerConnection() var capabilities = ServerCapabilities() capabilities.textDocumentSync = .optionA( @@ -56,12 +62,12 @@ final class LanguageServerDocumentTests: XCTestCase { save: nil ) ) - let server = LanguageServer( + let server = LanguageServerType( languageId: .swift, binary: .init(execPath: "", args: [], env: nil), lspInstance: InitializingServer( server: bufferingConnection, - initializeParamsProvider: LanguageServer.getInitParams(workspacePath: tempTestDir.path()) + initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: tempTestDir.path()) ), serverCapabilities: capabilities, rootPath: tempTestDir @@ -81,7 +87,7 @@ final class LanguageServerDocumentTests: XCTestCase { } func openCodeFile( - for server: LanguageServer, + for server: LanguageServerType, connection: BufferingServerConnection, file: CEWorkspaceFile, syncOption: TwoTypeOption? @@ -95,8 +101,11 @@ final class LanguageServerDocumentTests: XCTestCase { // This is usually sent from the LSPService try await server.openDocument(codeFile) - await waitForClientEventCount( - 3, + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen] + ), connection: connection, description: "Initialized (2) and opened (1) notification count" ) @@ -108,15 +117,18 @@ final class LanguageServerDocumentTests: XCTestCase { return codeFile } - func waitForClientEventCount(_ count: Int, connection: BufferingServerConnection, description: String) async { + func waitForClientState( + _ expectedValue: ([ClientRequest.Method], [ClientNotification.Method]), + connection: BufferingServerConnection, + description: String + ) async { let expectation = expectation(description: description) await withTaskGroup(of: Void.self) { group in + group.addTask { await self.fulfillment(of: [expectation], timeout: 2) } group.addTask { - await self.fulfillment(of: [expectation], timeout: 2) - } - group.addTask { - for await events in connection.clientEventSequence where events.0.count + events.1.count == count { + for await events in connection.clientEventSequence + where events.0.map(\.method) == expectedValue.0 && events.1.map(\.method) == expectedValue.1 { expectation.fulfill() return } @@ -124,6 +136,8 @@ final class LanguageServerDocumentTests: XCTestCase { } } + // MARK: - Open Close + @MainActor func testOpenCloseFileNotifications() async throws { // Set up test server @@ -153,30 +167,30 @@ final class LanguageServerDocumentTests: XCTestCase { file.fileDocument = codeFile CodeEditDocumentController.shared.addDocument(codeFile) - await waitForClientEventCount(3, connection: connection, description: "Pre-close event count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen] + ), + connection: connection, + description: "Pre-close event count" + ) // This should then trigger a documentDidClose event codeFile.close() - await waitForClientEventCount(4, connection: connection, description: "Post-close event count") - - XCTAssertEqual( - connection.clientRequests.map { $0.method }, - [ - ClientRequest.Method.initialize, - ] - ) - - XCTAssertEqual( - connection.clientNotifications.map { $0.method }, - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidClose - ] + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidClose] + ), + connection: connection, + description: "Post-close event count" ) } + // MARK: - Test Document Edit + /// Assert the changed contents received by the buffered connection func assertExpectedContentChanges(connection: BufferingServerConnection, changes: [String]) { var foundChangeContents: [String] = [] @@ -184,9 +198,7 @@ final class LanguageServerDocumentTests: XCTestCase { for notification in connection.clientNotifications { switch notification { case let .textDocumentDidChange(params): - foundChangeContents.append(contentsOf: params.contentChanges.map { event in - event.text - }) + foundChangeContents.append(contentsOf: params.contentChanges.map(\.text)) default: continue } @@ -231,18 +243,17 @@ final class LanguageServerDocumentTests: XCTestCase { textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") // Added one notification - await waitForClientEventCount(4, connection: connection, description: "Edited notification count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidChange] + ), + connection: connection, + description: "Edited notification count" + ) // Make sure our text view is intact XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) - XCTAssertEqual( - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidChange - ], - connection.clientNotifications.map { $0.method } - ) // Expect only one change due to throttling. assertExpectedContentChanges( @@ -289,18 +300,17 @@ final class LanguageServerDocumentTests: XCTestCase { textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") // Throttling means we should receive one edited notification + init notification + didOpen + init request - await waitForClientEventCount(4, connection: connection, description: "Edited notification count") + await waitForClientState( + ( + [.initialize], + [.initialized, .textDocumentDidOpen, .textDocumentDidChange] + ), + connection: connection, + description: "Edited notification count" + ) // Make sure our text view is intact XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) - XCTAssertEqual( - [ - ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidOpen, - ClientNotification.Method.textDocumentDidChange - ], - connection.clientNotifications.map { $0.method } - ) // Expect three content changes. assertExpectedContentChanges( diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift new file mode 100644 index 0000000000..76b2e8cf3c --- /dev/null +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift @@ -0,0 +1,80 @@ +// +// LanguageServer+DocumentObjects.swift +// CodeEditTests +// +// Created by Khan Winter on 2/12/25. +// + +import XCTest +import CodeEditTextView +import CodeEditSourceEditor +import CodeEditLanguages +import LanguageClient +import LanguageServerProtocol + +@testable import CodeEdit + +final class LanguageServerDocumentObjectsTests: XCTestCase { + final class MockDocumentType: LanguageServerDocument { + var content: NSTextStorage? + var languageServerURI: String? + var languageServerObjects: LanguageServerDocumentObjects + + init() { + self.content = NSTextStorage(string: "hello world") + self.languageServerURI = "/test/file/path" + self.languageServerObjects = .init() + } + + func getLanguage() -> CodeLanguage { + .swift + } + } + + typealias LanguageServerType = LanguageServer + + var document: MockDocumentType! + var server: LanguageServerType! + + // MARK: - Set Up + + override func setUp() async throws { + var capabilities = ServerCapabilities() + capabilities.textDocumentSync = .optionA(.init(openClose: true, change: .full)) + capabilities.semanticTokensProvider = .optionA(.init(legend: .init(tokenTypes: [], tokenModifiers: []))) + server = LanguageServerType( + languageId: .swift, + binary: .init(execPath: "", args: [], env: nil), + lspInstance: InitializingServer( + server: BufferingServerConnection(), + initializeParamsProvider: LanguageServerType.getInitParams(workspacePath: "/") + ), + serverCapabilities: capabilities, + rootPath: URL(fileURLWithPath: "") + ) + _ = try await server.lspInstance.initializeIfNeeded() + document = MockDocumentType() + } + + // MARK: - Tests + + func testOpenDocumentRegistersObjects() async throws { + try await server.openDocument(document) + XCTAssertNotNil(document.languageServerObjects.highlightProvider) + XCTAssertNotNil(document.languageServerObjects.textCoordinator) + XCTAssertNotNil(server.openFiles.document(for: document.languageServerURI ?? "")) + } + + func testCloseDocumentClearsObjects() async throws { + guard let languageServerURI = document.languageServerURI else { + XCTFail("Language server URI missing on a mock object") + return + } + try await server.openDocument(document) + XCTAssertNotNil(server.openFiles.document(for: languageServerURI)) + + try await server.closeDocument(languageServerURI) + XCTAssertNil(document.languageServerObjects.highlightProvider) + XCTAssertNil(document.languageServerObjects.textCoordinator) + } +} diff --git a/CodeEditTests/Features/LSP/SemanticTokenMapTests.swift b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift similarity index 79% rename from CodeEditTests/Features/LSP/SemanticTokenMapTests.swift rename to CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift index 4c941de1a4..a9ec5c5a3b 100644 --- a/CodeEditTests/Features/LSP/SemanticTokenMapTests.swift +++ b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenMapTests.swift @@ -10,7 +10,7 @@ import CodeEditSourceEditor import LanguageServerProtocol @testable import CodeEdit -final class SemanticTokenMapTestsTests: XCTestCase { +final class SemanticTokenMapTests: XCTestCase { // Ignores the line parameter and just returns a range from the char and length for testing struct MockRangeProvider: SemanticTokenMapRangeProvider { func nsRangeFrom(line: UInt32, char: UInt32, length: UInt32) -> NSRange? { @@ -53,10 +53,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { // Test decode tokens let tokens = SemanticTokens(tokens: [ - SemanticToken(line: 0, char: 0, length: 1, type: 0, modifiers: 0b11), // First two indices set + SemanticToken(line: 0, char: 0, length: 1, type: 1000000, modifiers: 0b11), // First two indices set SemanticToken(line: 0, char: 1, length: 2, type: 0, modifiers: 0b100100), // 6th and 3rd indices set - SemanticToken(line: 0, char: 4, length: 1, type: 0b1, modifiers: 0b101), - SemanticToken(line: 0, char: 5, length: 1, type: 0b100, modifiers: 0b1010), + SemanticToken(line: 0, char: 4, length: 1, type: 1, modifiers: 0b101), + SemanticToken(line: 0, char: 5, length: 1, type: 4, modifiers: 0b1010), SemanticToken(line: 0, char: 7, length: 10, type: 0, modifiers: 0) ]) let decoded = map.decode(tokens: tokens, using: mockProvider) @@ -69,10 +69,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { XCTAssertEqual(decoded[4].range, NSRange(location: 7, length: 10), "Decoded range") XCTAssertEqual(decoded[0].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[1].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[2].capture, .include, "Decoded Capture") - XCTAssertEqual(decoded[3].capture, .keyword, "Decoded Capture") - XCTAssertEqual(decoded[4].capture, nil, "No Decoded Capture") + XCTAssertEqual(decoded[1].capture, .include, "No Decoded Capture") + XCTAssertEqual(decoded[2].capture, .constructor, "Decoded Capture") + XCTAssertEqual(decoded[3].capture, .comment, "Decoded Capture") + XCTAssertEqual(decoded[4].capture, .include, "No Decoded Capture") XCTAssertEqual(decoded[0].modifiers, [.declaration, .definition], "Decoded Modifiers") XCTAssertEqual(decoded[1].modifiers, [.readonly, .defaultLibrary], "Decoded Modifiers") @@ -92,10 +92,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { // Test decode tokens let tokens = SemanticTokens(tokens: [ - SemanticToken(line: 0, char: 0, length: 1, type: 0, modifiers: 0b11), // First two indices set + SemanticToken(line: 0, char: 0, length: 1, type: 100, modifiers: 0b11), // First two indices set SemanticToken(line: 0, char: 1, length: 2, type: 0, modifiers: 0b100100), // 6th and 3rd indices set - SemanticToken(line: 0, char: 4, length: 1, type: 0b1, modifiers: 0b101), - SemanticToken(line: 0, char: 5, length: 1, type: 0b100, modifiers: 0b1010), + SemanticToken(line: 0, char: 4, length: 1, type: 1, modifiers: 0b101), + SemanticToken(line: 0, char: 5, length: 1, type: 4, modifiers: 0b1010), SemanticToken(line: 0, char: 7, length: 10, type: 0, modifiers: 0) ]) let decoded = map.decode(tokens: tokens, using: mockProvider) @@ -108,10 +108,10 @@ final class SemanticTokenMapTestsTests: XCTestCase { XCTAssertEqual(decoded[4].range, NSRange(location: 7, length: 10), "Decoded range") XCTAssertEqual(decoded[0].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[1].capture, nil, "No Decoded Capture") - XCTAssertEqual(decoded[2].capture, .include, "Decoded Capture") - XCTAssertEqual(decoded[3].capture, .keyword, "Decoded Capture") - XCTAssertEqual(decoded[4].capture, nil, "No Decoded Capture") + XCTAssertEqual(decoded[1].capture, .include, "No Decoded Capture") + XCTAssertEqual(decoded[2].capture, .constructor, "Decoded Capture") + XCTAssertEqual(decoded[3].capture, .comment, "Decoded Capture") + XCTAssertEqual(decoded[4].capture, .include, "No Decoded Capture") XCTAssertEqual(decoded[0].modifiers, [.declaration, .definition], "Decoded Modifiers") XCTAssertEqual(decoded[1].modifiers, [.readonly, .defaultLibrary], "Decoded Modifiers") diff --git a/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift new file mode 100644 index 0000000000..f2d0179caf --- /dev/null +++ b/CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift @@ -0,0 +1,199 @@ +// +// SemanticTokenStorageTests.swift +// CodeEdit +// +// Created by Khan Winter on 12/26/24. +// + +import Foundation +import Testing +import CodeEditSourceEditor +import LanguageServerProtocol +@testable import CodeEdit + +// For easier comparison while setting semantic tokens +extension SemanticToken: @retroactive Equatable { + public static func == (lhs: SemanticToken, rhs: SemanticToken) -> Bool { + lhs.type == rhs.type + && lhs.modifiers == rhs.modifiers + && lhs.line == rhs.line + && lhs.char == rhs.char + && lhs.length == rhs.length + } +} + +@Suite +struct SemanticTokenStorageTests { + let storage = SemanticTokenStorage() + + let semanticTokens = [ + SemanticToken(line: 0, char: 0, length: 10, type: 0, modifiers: 0), + SemanticToken(line: 1, char: 2, length: 5, type: 2, modifiers: 3), + SemanticToken(line: 3, char: 8, length: 10, type: 1, modifiers: 0) + ] + + @Test + func initialState() async throws { + #expect(storage.state == nil) + #expect(storage.hasReceivedData == false) + #expect(storage.lastResultId == nil) + } + + @Test + func setData() async throws { + storage.setData( + SemanticTokens( + resultId: "1234", + tokens: semanticTokens + ) + ) + + let state = try #require(storage.state) + #expect(state.tokens == semanticTokens) + #expect(state.resultId == "1234") + + #expect(storage.lastResultId == "1234") + #expect(storage.hasReceivedData == true) + } + + @Test + func overwriteDataRepeatedly() async throws { + let dataToApply: [(String?, [SemanticToken])] = [ + (nil, semanticTokens), + ("1", []), + ("2", semanticTokens.dropLast()), + ("3", semanticTokens) + ] + for (resultId, tokens) in dataToApply { + storage.setData(SemanticTokens(resultId: resultId, tokens: tokens)) + let state = try #require(storage.state) + #expect(state.tokens == tokens) + #expect(state.resultId == resultId) + #expect(storage.lastResultId == resultId) + #expect(storage.hasReceivedData == true) + } + } + + @Suite("ApplyDeltas") + struct TokensDeltasTests { + struct DeltaEdit { + let start: Int + let deleteCount: Int + let data: [Int] + + func makeString() -> String { + let dataString = data.map { String($0) }.joined(separator: ",") + return "{\"start\": \(start), \"deleteCount\": \(deleteCount), \"data\": [\(dataString)] }" + } + } + + func makeDelta(resultId: String, edits: [DeltaEdit]) throws -> SemanticTokensDelta { + // This is unfortunate, but there's no public initializer for these structs. + // So we have to decode them from JSON strings + let editsString = edits.map { $0.makeString() }.joined(separator: ",") + let deltasJSON = "{ \"resultId\": \"\(resultId)\", \"edits\": [\(editsString)] }" + let decoder = JSONDecoder() + let deltas = try decoder.decode(SemanticTokensDelta.self, from: Data(deltasJSON.utf8)) + return deltas + } + + let storage: SemanticTokenStorage + + let semanticTokens = [ + SemanticToken(line: 0, char: 0, length: 10, type: 0, modifiers: 0), + SemanticToken(line: 1, char: 2, length: 5, type: 2, modifiers: 3), + SemanticToken(line: 3, char: 8, length: 10, type: 1, modifiers: 0) + ] + + init() { + storage = SemanticTokenStorage() + storage.setData(SemanticTokens(tokens: semanticTokens)) + #expect(storage.state?.tokens == semanticTokens) + } + + @Test + func applyEmptyDeltasNoChange() throws { + let deltas = try makeDelta(resultId: "1", edits: []) + + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 3) + #expect(state.resultId == "1") + #expect(state.tokens == semanticTokens) + } + + @Test + func applyInsertDeltas() throws { + let deltas = try makeDelta(resultId: "1", edits: [.init(start: 0, deleteCount: 0, data: [0, 2, 3, 0, 1])]) + + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 4) + #expect(storage.lastResultId == "1") + + // Should have inserted one at the beginning + #expect(state.tokens[0].line == 0) + #expect(state.tokens[0].char == 2) + #expect(state.tokens[0].length == 3) + #expect(state.tokens[0].modifiers == 1) + + // We inserted a delta into the space before this one (at char 2) so this one starts at the same spot + #expect(state.tokens[1] == SemanticToken(line: 0, char: 2, length: 10, type: 0, modifiers: 0)) + #expect(state.tokens[2] == semanticTokens[1]) + #expect(state.tokens[3] == semanticTokens[2]) + } + + @Test + func applyDeleteOneDeltas() throws { + // Delete the second token (semanticTokens[1]) from the initial state. + // Each token is represented by 5 numbers, so token[1] starts at raw data index 5. + let deltas = try makeDelta(resultId: "2", edits: [.init(start: 5, deleteCount: 5, data: [])]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 2) + #expect(state.resultId == "2") + // The remaining tokens should be the first and third tokens, except we deleted one line between them + // so the third token's line is less one + #expect(state.tokens[0] == semanticTokens[0]) + #expect(state.tokens[1] == SemanticToken(line: 2, char: 8, length: 10, type: 1, modifiers: 0)) + } + + @Test + func applyDeleteManyDeltas() throws { + // Delete the first two tokens from the initial state. + // Token[0] and token[1] together use 10 integers. + let deltas = try makeDelta(resultId: "3", edits: [.init(start: 0, deleteCount: 10, data: [])]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 1) + #expect(state.resultId == "3") + // The only remaining token should be the original third token. + #expect(state.tokens[0] == SemanticToken(line: 2, char: 8, length: 10, type: 1, modifiers: 0)) + } + + @Test + func applyInsertAndDeleteDeltas() throws { + // Combined test: insert a token at the beginning and delete the last token. + // Edit 1: Insert a new token at the beginning. + let insertion = DeltaEdit(start: 0, deleteCount: 0, data: [0, 2, 3, 0, 1]) + // Edit 2: Delete the token that starts at raw data index 10 (the third token in the original state). + let deletion = DeltaEdit(start: 10, deleteCount: 5, data: []) + let deltas = try makeDelta(resultId: "4", edits: [insertion, deletion]) + _ = storage.applyDelta(deltas) + + let state = try #require(storage.state) + #expect(state.tokens.count == 3) + #expect(storage.lastResultId == "4") + // The new inserted token becomes the first token. + #expect(state.tokens[0] == SemanticToken(line: 0, char: 2, length: 3, type: 0, modifiers: 1)) + // The original first token is shifted (its character offset increased by 2). + #expect(state.tokens[1] == SemanticToken(line: 0, char: 2, length: 10, type: 0, modifiers: 0)) + // The second token from the original state remains unchanged. + #expect(state.tokens[2] == semanticTokens[1]) + } + } +} diff --git a/CodeEditTests/Features/Welcome/RecentProjectsTests.swift b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift new file mode 100644 index 0000000000..a3ad378141 --- /dev/null +++ b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift @@ -0,0 +1,98 @@ +// +// RecentProjectsTests.swift +// CodeEditTests +// +// Created by Khan Winter on 5/27/25. +// + +import Testing +import Foundation +@testable import CodeEdit + +// This suite needs to be serial due to the use of `UserDefaults` and sharing one testing storage location. +@Suite(.serialized) +class RecentProjectsTests { + let store: RecentProjectsStore + + init() { + let defaults = UserDefaults(suiteName: #file)! + defaults.removeSuite(named: #file) + store = RecentProjectsStore(defaults: defaults) + } + + deinit { + try? FileManager.default.removeItem(atPath: #file + ".plist") + } + + @Test + func newStoreEmpty() { + #expect(store.recentURLs().isEmpty) + } + + @Test + func savesURLs() { + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + + let recentURLs = store.recentURLs() + #expect(recentURLs.count == 2) + #expect(recentURLs[0].path(percentEncoded: false) == "Directory/file.txt") + #expect(recentURLs[1].path(percentEncoded: false) == "Directory/") + } + + @Test + func clearURLs() { + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + + #expect(store.recentURLs().count == 2) + + store.clearList() + + #expect(store.recentURLs().isEmpty) + } + + @Test + func duplicatesAreMovedToFront() { + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + // Move to front + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + // Remove duplicate + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + + let recentURLs = store.recentURLs() + #expect(recentURLs.count == 2) + + // Should be moved to the front of the list because it was 'opened' again. + #expect(recentURLs[0].path(percentEncoded: false) == "Directory/") + #expect(recentURLs[1].path(percentEncoded: false) == "Directory/file.txt") + } + + @Test + func removeSubset() { + store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) + store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + + let remaining = store.removeRecentProjects(Set([URL(filePath: "Directory/", directoryHint: .isDirectory)])) + + #expect(remaining == [URL(filePath: "Directory/file.txt")]) + let recentURLs = store.recentURLs() + #expect(recentURLs.count == 1) + #expect(recentURLs[0].path(percentEncoded: false) == "Directory/file.txt") + } + + @Test + func maxesOutAt100Items() { + for idx in 0..<101 { + store.documentOpened( + at: URL( + filePath: "file\(idx).txt", + directoryHint: Bool.random() ? .isDirectory : .notDirectory + ) + ) + } + + #expect(store.recentURLs().count == 100) + } +} diff --git a/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift b/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift new file mode 100644 index 0000000000..8141724902 --- /dev/null +++ b/CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift @@ -0,0 +1,20 @@ +// +// CEWorkspaceSettingsTests.swift +// CodeEditTests +// +// Created by Khan Winter on 4/21/25. +// + +import Foundation +import Testing +@testable import CodeEdit + +struct CEWorkspaceSettingsTests { + let settings: CEWorkspaceSettings = CEWorkspaceSettings(workspaceURL: URL(filePath: "/")) + + @Test + func settingsURLNoSpace() async throws { + #expect(settings.folderURL.lastPathComponent == ".codeedit") + #expect(settings.settingsURL.lastPathComponent == "settings.json") + } +} diff --git a/CodeEditTests/Utils/UnitTests_Extensions.swift b/CodeEditTests/Utils/UnitTests_Extensions.swift index de7a0fec3f..9118f1a1f6 100644 --- a/CodeEditTests/Utils/UnitTests_Extensions.swift +++ b/CodeEditTests/Utils/UnitTests_Extensions.swift @@ -196,4 +196,38 @@ final class CodeEditUtilsExtensionsUnitTests: XCTestCase { let path = #"/Hello World/ With Spaces/ And " Characters "# XCTAssertEqual(path.escapedDirectory(), #""/Hello World/ With Spaces/ And \" Characters ""#) } + + // MARK: - URL + Contains + + func testURLContainsSubPath() { + XCTAssertTrue(URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/file.txt"))) + XCTAssertFalse(URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/"))) + XCTAssertFalse(URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/"))) + XCTAssertTrue(URL(filePath: "/Users/Bob/Desktop").containsSubPath(URL(filePath: "/Users/Bob/Desktop/Folder"))) + } + + func testURLSharedComponentsCount() { + // URL Treats the leading `/` as a component, so these all appear to have + 1 but are correct. + XCTAssertEqual( + URL(filePath: "/Users/Bob/Desktop").sharedComponents(URL(filePath: "/Users/Bob/Desktop/file.txt")), + 4 + ) + XCTAssertEqual( + URL(filePath: "/Users/Bob/Desktop").sharedComponents(URL(filePath: "/Users/Bob/Desktop/")), + 4 + ) + XCTAssertEqual( + URL(filePath: "/Users/Bob/Desktop").sharedComponents(URL(filePath: "/Users/Bob/")), + 3 + ) + XCTAssertEqual( + URL(filePath: "/Users/Bob/Desktop").sharedComponents(URL(filePath: "/Users/Bob/Desktop/Folder")), + 4 + ) + + XCTAssertEqual( + URL(filePath: "/Users/Bob/Desktop").sharedComponents(URL(filePath: "/Some Other/ Path ")), + 1 + ) + } } diff --git a/CodeEditUITests/Other Tests/HideInterfaceTests.swift b/CodeEditUITests/Other Tests/HideInterfaceTests.swift new file mode 100644 index 0000000000..bf804747ca --- /dev/null +++ b/CodeEditUITests/Other Tests/HideInterfaceTests.swift @@ -0,0 +1,184 @@ +// +// HiderInterfaceTests.swift +// CodeEditUITests +// +// Created by Simon Kudsk on 14/05/2025. +// + +import XCTest +final class HideInterfaceUITests: XCTestCase { + + // MARK: – Setup + private var app: XCUIApplication! + private var path: String! + + override func setUp() async throws { + try await MainActor.run { + (app, path) = try App.launchWithTempDir() + } + } + + /// List of the panels to test with + private let allPanels: () -> [String] = { + ["Navigator", "Inspector", "Utility Area", "Toolbar"] + } + + // MARK: – Tests + + /// Test 1: Ensure each panel can show and hide individually. + func testPanelsShowAndHideIndividually() { + let viewMenu = app.menuBars.menuBarItems["View"] + for panel in allPanels() { + // Show panel + let showItem = "Show \(panel)" + if viewMenu.menuItems[showItem].exists { + viewMenu.menuItems[showItem].click() + } + + // Verify panel is visible + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Hide \(panel)"].exists, "\(panel) should be visible after show") + + // Hide panel and verify it being hidden + viewMenu.menuItems[("Hide \(panel)")].click() + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Show \(panel)"].exists, "\(panel) should be hidden after hide") + } + } + + /// Test 2: Hide interface hides all panels. + func testHideInterfaceHidesAllPanels() { + let viewMenu = app.menuBars.menuBarItems["View"] + // Ensure all panels are shown + for panel in allPanels() { + let showItem = "Show \(panel)" + if viewMenu.menuItems[showItem].exists { + viewMenu.menuItems[showItem].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Verify all panels are hidden + viewMenu.click() + for panel in allPanels() { + XCTAssertTrue(viewMenu.menuItems["Show \(panel)"].exists, "\(panel) should be hidden") + } + } + + /// Test 3: Show interface shows all panels when none are visible. + func testShowInterfaceShowsAllWhenNoneVisible() { + let viewMenu = app.menuBars.menuBarItems["View"] + // Ensure all panels are hidden + for panel in allPanels() { + let hideItem = "Hide \(panel)" + if viewMenu.menuItems[hideItem].exists { + viewMenu.menuItems[hideItem].click() + } + } + + // Verify button says Show Interface + viewMenu.click() + XCTAssertTrue(viewMenu.menuItems["Show Interface"].exists, "Interface button should say Show Interface") + + // Show interface without waiting + viewMenu.menuItems[("Show Interface")].click() + + // Verify all panels are shown + viewMenu.click() + for panel in allPanels() { + XCTAssertTrue( + viewMenu.menuItems["Hide \(panel)"].exists, + "\(panel) should be visible after showing interface" + ) + } + } + + /// Test 4: Show interface restores previous panel state. + func testShowInterfaceRestoresPreviousState() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial state + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide then show interface + viewMenu.menuItems[("Hide Interface")].click() + viewMenu.menuItems[("Show Interface")].click() + + // Verify only initial panels are shown + viewMenu.click() + for panel in allPanels() { + let shouldBeVisible = initialOpen.contains(panel) + XCTAssertEqual(viewMenu.menuItems["Hide \(panel)"].exists, shouldBeVisible, "\(panel) visibility mismatch") + } + } + + /// Test 5: Individual toggles after hide update the interface button. + func testIndividualTogglesUpdateInterfaceButton() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial visibility + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Individually enable initial panels + for panel in initialOpen { + viewMenu.menuItems[("Show \(panel)")].click() + } + + // Verify interface button resets to Hide Interface + viewMenu.click() + XCTAssertTrue( + viewMenu.menuItems["Hide Interface"].exists, + "Interface should say hide interface when all previous panels are enabled again" + ) + } + + /// Test 6: Partial show after hide restores correct panels. + func testPartialShowAfterHideRestoresCorrectPanels() { + let viewMenu = app.menuBars.menuBarItems["View"] + let initialOpen = ["Navigator", "Toolbar"] + + // Set initial visibility + for panel in allPanels() { + let item = initialOpen.contains(panel) ? "Show \(panel)" : "Hide \(panel)" + if viewMenu.menuItems[item].exists { + viewMenu.menuItems[item].click() + } + } + + // Hide interface + viewMenu.menuItems[("Hide Interface")].click() + + // Individually enable navigator and inspector + for panel in ["Navigator", "Inspector"] { + viewMenu.menuItems[("Show \(panel)")].click() + } + // Show interface + viewMenu.menuItems[("Show Interface")].click() + + // Verify correct panels are shown + viewMenu.click() + for panel in ["Navigator", "Inspector", "Toolbar"] { + XCTAssertTrue(viewMenu.menuItems["Hide \(panel)"].exists, "\(panel) should be visible") + } + + // Utility Area should remain hidden + XCTAssertTrue(viewMenu.menuItems["Show Utility Area"].exists, "Utility Area should be hidden") + } +} diff --git a/README.md b/README.md index f5b9651ae6..c54a649114 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,8 @@ For issues we want to focus on that are most relevant at any given time, please Filipp Kuznetsov
Filipp Kuznetsov

💻 + rustemd02
rustemd02

🐛 💻 + Simon Kudsk
Simon Kudsk

💻