From 9989d2ef64a5ddbad80638c87585c277e88a8be8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:55:43 -0500 Subject: [PATCH 01/14] Fix Tasks Not Saving (#2034) ### Description Fixes a bug where tasks would not save after creating them due to an accidental change when removing a deprecated URL method. Also removes a few unnecessary view models in settings, and correctly handles errors thrown when saving workspace settings in UI (with a simple alert). ### Related Issues * N/A Reported on [discord](https://discord.com/channels/951544472238444645/952640521812193411/1362011473324540097) ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots --- .../Models/CEWorkspaceSettings.swift | 16 ++++++++++--- .../Views/CETaskFormView.swift | 6 ----- .../Views/CEWorkspaceSettingsView.swift | 3 --- .../Views/EditCETaskView.swift | 24 ++++++++++++------- .../CEWorkspaceSettingsTests.swift | 20 ++++++++++++++++ 5 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 CodeEditTests/Features/WorkspaceSettings/CEWorkspaceSettingsTests.swift diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift b/CodeEdit/Features/CEWorkspaceSettings/Models/CEWorkspaceSettings.swift index 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/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") + } +} From dabc17b85fa78853339ca502706d3bb133e7e150 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 26 Apr 2025 20:53:31 -0500 Subject: [PATCH 02/14] Add A Minimap (#2032) ### Description Adds a minimap to CodeEdit's editor, as well as a new trailing editor accessory (that only appears when the selected document is a code document), a command to toggle the minimap, and a setting to toggle the minimap. ### Related Issues * https://github.com/CodeEditApp/CodeEditSourceEditor/issues/33 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/07f21d48-23cf-42dc-b39a-02ba395956cb --- CodeEdit.xcodeproj/project.pbxproj | 6 +-- .../xcshareddata/swiftpm/Package.resolved | 8 ++-- .../JumpBar/Views/EditorJumpBarView.swift | 6 ++- .../EditorTabBarTrailingAccessories.swift | 46 +++++++++++++++++-- .../TabBar/Views/EditorTabBarView.swift | 3 +- .../Features/Editor/Views/CodeFileView.swift | 5 +- .../Editor/Views/EditorAreaView.swift | 5 +- .../Models/TextEditingSettings.swift | 20 ++++++-- .../TextEditingSettingsView.swift | 7 +++ 9 files changed, 86 insertions(+), 20 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2a95476a01..56acfca167 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 */; @@ -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; + minimumVersion = 0.12.0; }; }; /* 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..465ebbcf1b 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" : "412b0a26cbeb3f3148a1933dd598c976defe92a6", + "version" : "0.12.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", - "version" : "0.8.2" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { diff --git a/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift b/CodeEdit/Features/Editor/JumpBar/Views/EditorJumpBarView.swift index 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..eeb8586cf2 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -44,6 +44,8 @@ struct CodeFileView: View { var bracketEmphasis @AppSettings(\.textEditing.useSystemCursor) var useSystemCursor + @AppSettings(\.textEditing.showMinimap) + var showMinimap @Environment(\.colorScheme) private var colorScheme @@ -125,7 +127,8 @@ struct CodeFileView: View { bracketPairEmphasis: getBracketPairEmphasis(), useSystemCursor: useSystemCursor, undoManager: undoManager, - coordinators: textViewCoordinators + coordinators: textViewCoordinators, + showMinimap: showMinimap ) .id(codeFile.fileURL) .background { diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 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/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index 26e760f1ee..3fa6a6bfcd 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -27,7 +27,8 @@ extension SettingsData { "Autocomplete braces", "Enable type-over completion", "Bracket Pair Emphasis", - "Bracket Pair Highlight" + "Bracket Pair Highlight", + "Show Minimap", ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -70,6 +71,9 @@ extension SettingsData { /// Use the system cursor for the source editor. var useSystemCursor: Bool = true + /// Toggle the minimap in the editor. + var showMinimap: Bool = true + /// Default initializer init() { self.populateCommands() @@ -118,6 +122,8 @@ extension SettingsData { self.useSystemCursor = false } + self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true + self.populateCommands() } @@ -130,7 +136,7 @@ extension SettingsData { title: "Toggle Type-Over Completion", id: "prefs.text_editing.type_over_completion", command: { - Settings.shared.preferences.textEditing.enableTypeOverCompletion.toggle() + Settings[\.textEditing].enableTypeOverCompletion.toggle() } ) @@ -139,7 +145,7 @@ extension SettingsData { title: "Toggle Autocomplete Braces", id: "prefs.text_editing.autocomplete_braces", command: { - Settings.shared.preferences.textEditing.autocompleteBraces.toggle() + Settings[\.textEditing].autocompleteBraces.toggle() } ) @@ -151,6 +157,14 @@ extension SettingsData { Settings[\.textEditing].wrapLinesToEditorWidth.toggle() } ) + + mgr.addCommand( + name: "Toggle Minimap", + title: "Toggle Minimap", + id: "prefs.text_editing.toggle_minimap" + ) { + Settings[\.textEditing].showMinimap.toggle() + } } struct IndentOption: Codable, Hashable { diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index 03b78a8c06..fdaeeaaf44 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -20,6 +20,7 @@ struct TextEditingSettingsView: View { wrapLinesToEditorWidth useSystemCursor overscroll + showMinimap } Section { fontSelector @@ -199,4 +200,10 @@ private extension TextEditingSettingsView { } } } + + @ViewBuilder private var showMinimap: some View { + Toggle("Show Minimap", isOn: $textEditing.showMinimap) + // swiftlint:disable:next line_length + .help("The minimap gives you a high-level summary of your source code, with controls to quickly navigate your document.") + } } From a6efad2d6d4f05eea553306084a0bb6176e1204f Mon Sep 17 00:00:00 2001 From: Kihron Date: Tue, 6 May 2025 13:47:09 -0400 Subject: [PATCH 03/14] Fix Getting Stuck in Sub-View within Settings (#2038) ### Description This pull request resolves several navigation related bugs within Settings. The most important being that it resolves getting suck within sub views. Additionally, I've improved how hiding the sidebar toggle works which is now consistently hidden regardless of macOS 13+. It now no longer makes unexpected returns or cameos. ### Related Issues * closes #1923 ### Checklist - [X] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [X] The issues this PR addresses are related to each other - [X] My changes generate no new warnings - [X] My code builds and runs on my machine - [X] My changes are all related to the related issue above - [X] I documented my code ### Screenshots https://github.com/user-attachments/assets/f92bcc49-b397-4e33-85c5-06d0873e6b10 --- CodeEdit/Features/Settings/SettingsView.swift | 2 +- .../Settings/Views/SettingsForm.swift | 58 ++++++++++--------- .../Views/View+HideSidebarToggle.swift | 12 ++-- .../View+NavigationBarBackButtonVisible.swift | 6 +- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index 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 + } } } From 6689fd30ac0125672c625996f6c117628ad2becb Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 9 May 2025 13:57:56 -0500 Subject: [PATCH 04/14] Fixed History Inspector popover UI bug (#2041) --- .../InspectorArea/HistoryInspector/HistoryPopoverView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryPopoverView.swift index 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) From f2850b0fc446170da9f102e8b98c42e21f9d8782 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 9 May 2025 14:51:55 -0500 Subject: [PATCH 05/14] Language Server Syntax Highlights (#1985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Adds semantic token syntax highlighting to the code file view. When an LSP is installed and configured for a language type, and has semantic highlights support, CodeEdit will install a new highlight provider on the source editor and begin processing syntax tokens for the file. Token processing happens asynchronously, and does **not** replace tree-sitter highlights. This builds off recent work in the source editor to support a hierarchy of highlight providers. Language server highlights are slow but more accurate, so we process them slowly and apply them when they become available. #### Details - Adds a new generic 'language server document' protocol that includes only what the language server code needs to know about a code document. This should solve the coupling issue we had with CodeFileDocument and the language server code. In the future, if we replace `CodeFileDocument`, it'll be a matter of conforming the new type to the protocol for it to work with the lsp code. - Reorganizes slightly to group lsp features into their own "Features" folder. - Adds a new `SemanticTokenHighlightProvider` type - Conforms to the `HighlightProviding` protocol. - Manages receiving edit notifications from the editor and forwards them to the language server service. - Adds a `SemanticTokenMap` type - Maps LSP semantic token data to a format CodeEdit can read. - Reads a LSP's capabilities to determine how to decode that data. - Adds `SemanticTokenStorage` - Provides an API for applying token deltas, and entire file token data. - Manages decoding, re-decoding (when dealing with deltas) and storing semantic tokens. - Provides an API for finding semantic tokens quickly. ### Related Issues * closes #1950 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots ![Screenshot 2025-02-14 at 10 20 09 AM](https://github.com/user-attachments/assets/14ee65a3-058c-4f9c-b816-ae258aca96be) Live editing demo, note the highlights on the variable types and switch cases. https://github.com/user-attachments/assets/e70bf93c-779d-412b-9b34-c68e46898921 --- .../CodeFileDocument/CodeFileDocument.swift | 23 +- .../Features/Editor/Views/CodeFileView.swift | 21 +- .../DocumentSync}/LSPContentCoordinator.swift | 18 +- .../SemanticTokenHighlightProvider.swift | 172 +++++++++++++++ .../SemanticTokens}/SemanticTokenMap.swift | 19 +- .../SemanticTokenMapRangeProvider.swift | 0 .../GenericSemanticTokenStorage.swift | 25 +++ .../SemanticTokenRange.swift | 13 ++ .../SemanticTokenStorage.swift | 180 ++++++++++++++++ .../LanguageServer+DocumentSync.swift | 37 +++- .../LanguageServer+SemanticTokens.swift | 18 +- .../LSP/LanguageServer/LanguageServer.swift | 15 +- .../LanguageServerFileMap.swift | 53 +++-- .../Features/LSP/LanguageServerDocument.swift | 23 ++ .../Features/LSP/Service/LSPService.swift | 49 ++--- .../LSP/Service/LSPServiceError.swift | 13 ++ .../SemanticToken+Position.swift | 18 ++ .../TextView+SemanticTokenRangeProvider.swift | 5 + .../Utils/Extensions/URL/URL+LSPURI.swift | 18 ++ ... => LanguageServer+CodeFileDocument.swift} | 112 +++++----- .../LSP/LanguageServer+DocumentObjects.swift | 80 +++++++ .../SemanticTokenMapTests.swift | 30 +-- .../SemanticTokenStorageTests.swift | 199 ++++++++++++++++++ 23 files changed, 982 insertions(+), 159 deletions(-) rename CodeEdit/Features/LSP/{Editor => Features/DocumentSync}/LSPContentCoordinator.swift (87%) create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenHighlightProvider.swift rename CodeEdit/Features/LSP/{Editor => Features/SemanticTokens}/SemanticTokenMap.swift (80%) rename CodeEdit/Features/LSP/{Editor => Features/SemanticTokens}/SemanticTokenMapRangeProvider.swift (100%) create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/GenericSemanticTokenStorage.swift create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenRange.swift create mode 100644 CodeEdit/Features/LSP/Features/SemanticTokens/SemanticTokenStorage/SemanticTokenStorage.swift create mode 100644 CodeEdit/Features/LSP/LanguageServerDocument.swift create mode 100644 CodeEdit/Features/LSP/Service/LSPServiceError.swift create mode 100644 CodeEdit/Utils/Extensions/SemanticToken/SemanticToken+Position.swift create mode 100644 CodeEdit/Utils/Extensions/URL/URL+LSPURI.swift rename CodeEditTests/Features/LSP/{LanguageServer+DocumentTests.swift => LanguageServer+CodeFileDocument.swift} (80%) create mode 100644 CodeEditTests/Features/LSP/LanguageServer+DocumentObjects.swift rename CodeEditTests/Features/LSP/{ => SemanticTokens}/SemanticTokenMapTests.swift (79%) create mode 100644 CodeEditTests/Features/LSP/SemanticTokens/SemanticTokenStorageTests.swift diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 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/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index eeb8586cf2..ae5e167ade 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -19,9 +19,13 @@ struct CodeFileView: View { /// The current cursor positions in the view @State private var cursorPositions: [CursorPosition] = [] + @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() + /// Any coordinators passed to the view. private var textViewCoordinators: [TextViewCoordinator] + @State private var highlightProviders: [any HighlightProviding] = [] + @AppSettings(\.textEditing.defaultTabWidth) var defaultTabWidth @AppSettings(\.textEditing.indentOption) @@ -62,9 +66,10 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) + self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator] - + [codeFile.lspCoordinator].compactMap({ $0 }) + + [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 }) self.isEditable = isEditable if let openOptions = codeFile.openOptions { @@ -72,6 +77,8 @@ struct CodeFileView: View { self.cursorPositions = openOptions.cursorPositions } + updateHighlightProviders() + codeFile .contentCoordinator .textUpdatePublisher @@ -119,7 +126,7 @@ struct CodeFileView: View { editorOverscroll: overscroll.overscrollPercentage, cursorPositions: $cursorPositions, useThemeBackground: useThemeBackground, - highlightProviders: [treeSitter], + highlightProviders: highlightProviders, contentInsets: edgeInsets.nsEdgeInsets, additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0), isEditable: isEditable, @@ -144,6 +151,10 @@ struct CodeFileView: View { .onChange(of: settingsFont) { newFontSetting in font = newFontSetting.current } + .onReceive(codeFile.$languageServerObjects) { languageServerObjects in + // This will not be called in single-file views (for now) but is safe to listen to either way + updateHighlightProviders(lspHighlightProvider: languageServerObjects.highlightProvider) + } } /// Determines the style of bracket emphasis based on the `bracketEmphasis` setting and the current theme. @@ -166,6 +177,12 @@ struct CodeFileView: View { return .underline(color: color) } } + + /// Updates the highlight providers array. + /// - Parameter lspHighlightProvider: The language server provider, if available. + private func updateHighlightProviders(lspHighlightProvider: HighlightProviding? = nil) { + highlightProviders = [lspHighlightProvider].compactMap({ $0 }) + [treeSitterClient] + } } // This extension is kept here because it should not be used elsewhere in the app and may cause confusion diff --git a/CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift similarity index 87% rename from CodeEdit/Features/LSP/Editor/LSPContentCoordinator.swift rename to CodeEdit/Features/LSP/Features/DocumentSync/LSPContentCoordinator.swift index 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/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/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]) + } + } +} From bcec2a5b7dd414741fe5555abf34c9f570806856 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 24 May 2025 09:34:15 +0200 Subject: [PATCH 06/14] Open New Files in Workspace (#2043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description When a new file is opened, tries to open it in a nearby workspace. Right now new files are opened in their own window no matter what (eg using `⌘N` or a file URL). The workspace is chosen by proximity, so if a user has `/User/Desktop` and `/User/Desktop/Project` open. Opening the file `/User/Desktop/Project/MAKEFILE` will open it in the `Project` workspace. ### Related Issues * Reported by @ BSM on Discord ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots (please ignore the messed up gutter, it's been fixed in CESE but not cut in a release) https://github.com/user-attachments/assets/8d341dee-d043-4b0a-8fd3-f544bf952abb Files open to the nearest folder, and bring the window to front. https://github.com/user-attachments/assets/96ec98a5-20ec-4d53-a996-446d9264e7c4 --- .../Models/CEWorkspaceFileManager.swift | 8 ++--- .../CodeEditDocumentController.swift | 27 +++++++++++++- .../Extensions/URL/URL+componentCompare.swift | 35 +++++++++++++++++++ .../Utils/UnitTests_Extensions.swift | 34 ++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) 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/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index 034d3de75e..446fcb5e66 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 { @@ -73,6 +76,28 @@ final class CodeEditDocumentController: NSDocumentController { } } + /// 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/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/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 + ) + } } From a5d8fa73b3cc5d4e2318c74c1b2ef6533a596ae7 Mon Sep 17 00:00:00 2001 From: Abe Date: Sat, 24 May 2025 10:08:13 -0700 Subject: [PATCH 07/14] Fix Find Navigator resizing delay, use line height from user settings (#2018) * Fix resizing animaition, use line height from pref * Update sizing --- .../FindNavigatorListViewController.swift | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) 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.. Date: Tue, 27 May 2025 15:14:51 -0500 Subject: [PATCH 08/14] [chore:] Update CodeEditSourceEditor to `0.13.1` (#2046) * Updated CESE to 0.13.0. Added reformatAtColumn and showReformattingGuide to settings. * Update CodeEditSourceEditor to `0.13.1` --------- Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> --- CodeEdit.xcodeproj/project.pbxproj | 6 +++--- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ .../Features/Editor/Views/CodeFileView.swift | 8 +++++++- .../Models/TextEditingSettings.swift | 13 +++++++++++++ .../TextEditingSettingsView.swift | 18 ++++++++++++++++++ 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 56acfca167..d452958c2e 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -1622,7 +1622,7 @@ repositoryURL = "https://github.com/CodeEditApp/CodeEditSymbols"; requirement = { kind = exactVersion; - version = 0.2.2; + version = 0.2.3; }; }; 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */ = { @@ -1749,8 +1749,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.12.0; + kind = exactVersion; + version = 0.13.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 465ebbcf1b..eeaa99769b 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" : "412b0a26cbeb3f3148a1933dd598c976defe92a6", - "version" : "0.12.0" + "revision" : "1109665dfd20f02b5f7c919fd4e4aecb5027f7db", + "version" : "0.13.1" } }, { @@ -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" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" + "revision" : "d51f3ad8370457acc431fa01eede555c3e58b86d", + "version" : "0.11.0" } }, { diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index ae5e167ade..ef01751319 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -50,6 +50,10 @@ struct CodeFileView: View { var useSystemCursor @AppSettings(\.textEditing.showMinimap) var showMinimap + @AppSettings(\.textEditing.reformatAtColumn) + var reformatAtColumn + @AppSettings(\.textEditing.showReformattingGuide) + var showReformattingGuide @Environment(\.colorScheme) private var colorScheme @@ -135,7 +139,9 @@ struct CodeFileView: View { useSystemCursor: useSystemCursor, undoManager: undoManager, coordinators: textViewCoordinators, - showMinimap: showMinimap + showMinimap: showMinimap, + reformatAtColumn: reformatAtColumn, + showReformattingGuide: showReformattingGuide ) .id(codeFile.fileURL) .background { diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index 3fa6a6bfcd..ba54a47257 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -29,6 +29,8 @@ extension SettingsData { "Bracket Pair Emphasis", "Bracket Pair Highlight", "Show Minimap", + "Reformat at Column", + "Show Reformatting Guide", ] if #available(macOS 14.0, *) { keys.append("System Cursor") @@ -74,6 +76,12 @@ extension SettingsData { /// Toggle the minimap in the editor. var showMinimap: Bool = true + /// The column at which to reformat text + var reformatAtColumn: Int = 80 + + /// Show the reformatting guide in the editor + var showReformattingGuide: Bool = false + /// Default initializer init() { self.populateCommands() @@ -123,6 +131,11 @@ extension SettingsData { } self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true + self.reformatAtColumn = try container.decodeIfPresent(Int.self, forKey: .reformatAtColumn) ?? 80 + self.showReformattingGuide = try container.decodeIfPresent( + Bool.self, + forKey: .showReformattingGuide + ) ?? false self.populateCommands() } diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift index fdaeeaaf44..d0b2bf22a6 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/TextEditingSettingsView.swift @@ -21,6 +21,7 @@ struct TextEditingSettingsView: View { useSystemCursor overscroll showMinimap + reformatSettings } Section { fontSelector @@ -206,4 +207,21 @@ private extension TextEditingSettingsView { // swiftlint:disable:next line_length .help("The minimap gives you a high-level summary of your source code, with controls to quickly navigate your document.") } + + @ViewBuilder private var reformatSettings: some View { + Stepper( + "Reformat at Column", + value: Binding( + 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") + } } From 445f338c63f23590a930b64779e0b7931d8de94d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 29 May 2025 09:53:59 -0500 Subject: [PATCH 09/14] [chore:] Update CodeEditSourceEditor to 0.13.2 (#2050) Updates CodeEditSourceEditor to `0.13.2`, with a few hotfixes for editing and the minimap. --- CodeEdit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index d452958c2e..448a0cc391 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -1750,7 +1750,7 @@ repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { kind = exactVersion; - version = 0.13.1; + 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 eeaa99769b..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" : "1109665dfd20f02b5f7c919fd4e4aecb5027f7db", - "version" : "0.13.1" + "revision" : "30eb8a8cf3b291c91da04cfbc6683bee643b86a6", + "version" : "0.13.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "d51f3ad8370457acc431fa01eede555c3e58b86d", - "version" : "0.11.0" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { From c0eac3b901cf73baa1b263d478590b626c843fc6 Mon Sep 17 00:00:00 2001 From: Simon Kudsk <10168417+SimonKudsk@users.noreply.github.com> Date: Thu, 29 May 2025 17:40:54 +0200 Subject: [PATCH 10/14] Hide interface (#2044) * Added initial hide interface button - Hide interface and shortcut will store the interface state and hide it - Show interface will restore the interface state * Hiding interface now remembers better - When hiding interface it will better be able to handle hidden elements being enabled, and then showing the interface again. - Moved some logic from ViewCommands to CodeEditWindowController, as it seems like a more appropriate place. - Added a bool shouldAnimate to function toggleFirstPanel. This is by default on, and thus using the default animation. When explicitly called with off, the function will not animate. * interfaceHidden will now update itself on changes - Removed need for interfaceHidden bool - Removed need for resetting stored interface state - Added function isInterfaceStillHidden, checking whether "at least" the previous elements are visible again, taking other elements into account - Hidden animation for lastpanel * Updated logic to when no panels are active - If no panels are active, and interface hasn't been hidden, all panels will show when toggle interface is clicked - Renamed objc functions with prefix 'objc' - Re-added resetStoredInterfaceCollapseState - turns out it was necessary * Disabled animation for utility area when hiding interface - Added command "open.drawer.no.animation" to command manager. This will toggle the utility area, without an animation. - Added option possibility of no animation to SplitViewItem.Update - Added struct SplitViewItemCanAnimateViewTraitKey and function splitViewCanAnimate to SplitViewModifiers. These optionally allow disabling animations for SplitViews. - Updated "Hide Interface", WorkspaceView and UtilityAreaViewModel to accommodate these changes * Rewrote and moved hide interface logic - The logic for hide interface has been moved to a new file, CodeEditWindowsController+Panels. - The function for toggling first panel and last panel has also been moved to said file. - The logic for hide interface is now much more simplified, dynamic and easier to maintain. * Asynchronous reset of stored state - In isInterfaceStillHidden(), resetStoredInterfaceCollapseState() will now be called asynchronously to avoid a SwiftUI update warning * Removed comma - Removed a comma that was causing the "Testing CodeEdit" task to fail * Added UI Tests for HideInterfaceTest behaviour * Add Toolbar Visible State to Window Restoration * Fix Failing Test after Removing Command --------- Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> --- .../CodeEditWindowController+Panels.swift | 179 +++++++++++++++++ .../CodeEditWindowController+Toolbar.swift | 7 +- .../CodeEditWindowController.swift | 14 +- .../CodeEditWindowControllerExtensions.swift | 20 -- .../WorkspaceDocument/WorkspaceStateKey.swift | 1 + .../SplitView/Model/SplitViewItem.swift | 8 +- .../SplitView/Views/SplitViewModifiers.swift | 8 + .../ViewModels/UtilityAreaViewModel.swift | 6 +- .../WindowCommands/ViewCommands.swift | 10 + CodeEdit/WorkspaceView.swift | 1 + .../Other Tests/HideInterfaceTests.swift | 184 ++++++++++++++++++ 11 files changed, 410 insertions(+), 28 deletions(-) create mode 100644 CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Panels.swift create mode 100644 CodeEditUITests/Other Tests/HideInterfaceTests.swift 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/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/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/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/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") + } +} From 76d4d01c7277b241c5a383dc4e51079d6a150e36 Mon Sep 17 00:00:00 2001 From: rustemd02 Date: Thu, 29 May 2025 19:26:17 +0300 Subject: [PATCH 11/14] Added check for git installation presence (#2047) * added check for git installation presence * Update CodeEdit/Features/SourceControl/Clone/ViewModels/GitCloneViewModel.swift --------- Co-authored-by: Austin Condiff --- .../Clone/ViewModels/GitCloneViewModel.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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", From 4a8ee2c9a4cef07deb766c4e7e397ee0f78e2135 Mon Sep 17 00:00:00 2001 From: atsrus Date: Thu, 29 May 2025 17:29:52 +0100 Subject: [PATCH 12/14] Separated files and folders in the "Open Recent" sub-menu (#2039) * Issue #1061 Recents menu * Tidying comment * Merge Folders and Files Storage, Make Class * Update Menu And ListView * Add Tests * Fix Icon For Paths With Spaces, Spelling Error * Rename To `shared`, Test Inserted Order * Complete Test Coverage * Debug-only Init --------- Co-authored-by: atsrus Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> --- .../CodeEditDocumentController.swift | 2 +- .../Welcome/Model/RecentProjectsStore.swift | 105 ++++++++++++------ .../Views/RecentProjectsListView.swift | 14 ++- .../Utils/RecentProjectsMenu.swift | 53 +++++---- CodeEditTestPlan.xctestplan | 1 - .../Welcome/RecentProjectsTests.swift | 98 ++++++++++++++++ 6 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 CodeEditTests/Features/Welcome/RecentProjectsTests.swift diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index 446fcb5e66..256fb08187 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift @@ -71,7 +71,7 @@ final class CodeEditDocumentController: NSDocumentController { print("Unable to open document '\(url)': \(errorMessage)") } - RecentProjectsStore.documentOpened(at: url) + RecentProjectsStore.shared.documentOpened(at: url) completionHandler(document, documentWasAlreadyOpen, error) } } 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/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/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) + } +} From 6ef2f575f3138e1cccd846af9962a09224ac81b3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 11:35:37 -0500 Subject: [PATCH 13/14] docs: add rustemd02 as a contributor for bug, and code (#2052) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index f39c761437..ca71517a2c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -794,6 +794,16 @@ "contributions": [ "code" ] + }, + { + "login": "rustemd02", + "name": "rustemd02", + "avatar_url": "https://avatars.githubusercontent.com/u/11714456?v=4", + "profile": "https://github.com/rustemd02", + "contributions": [ + "bug", + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index f5b9651ae6..66c23100d2 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ For issues we want to focus on that are most relevant at any given time, please Filipp Kuznetsov
Filipp Kuznetsov

💻 + rustemd02
rustemd02

🐛 💻 From 41b590da0243f3494f1982ea988931fa33a28816 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 11:36:42 -0500 Subject: [PATCH 14/14] docs: add SimonKudsk as a contributor for code (#2053) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index ca71517a2c..0031e32fd9 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -804,6 +804,15 @@ "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/README.md b/README.md index 66c23100d2..c54a649114 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ 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

💻