From 89ccc14ca31f8d4a27a0d9552e1fb9f20f540634 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 28 Oct 2024 22:33:02 -0500 Subject: [PATCH 01/27] Hiding source control navigator when source control is disabled in Settings. --- .../Views/NavigatorAreaView.swift | 41 +++++++++++++------ .../SourceControlGeneralView.swift | 10 ++++- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift index 8feffa6ce..5e3d868ba 100644 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift +++ b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift @@ -15,22 +15,13 @@ struct NavigatorAreaView: View { @AppSettings(\.general.navigatorTabBarPosition) var sidebarPosition: SettingsData.SidebarTabBarPosition + @AppSettings(\.sourceControl.general.enableSourceControl) + private var enableSourceControl: Bool + init(workspace: WorkspaceDocument, viewModel: NavigatorSidebarViewModel) { self.workspace = workspace self.viewModel = viewModel - - viewModel.tabItems = [.project, .sourceControl, .search] + - extensionManager - .extensions - .map { ext in - ext.availableFeatures.compactMap { - if case .sidebarItem(let data) = $0, data.kind == .navigator { - return NavigatorTab.uiExtension(endpoint: ext.endpoint, data: data) - } - return nil - } - } - .joined() + updateTabItems() } var body: some View { @@ -63,5 +54,29 @@ struct NavigatorAreaView: View { .environmentObject(workspace) .accessibilityElement(children: .contain) .accessibilityLabel("navigator") + .onChange(of: enableSourceControl) { _ in + updateTabItems() + } + } + + private func updateTabItems() { + viewModel.tabItems = [.project] + + (enableSourceControl ? [.sourceControl] : []) + + [.search] + + extensionManager + .extensions + .flatMap { ext in + ext.availableFeatures.compactMap { + if case .sidebarItem(let data) = $0, data.kind == .navigator { + return NavigatorTab.uiExtension(endpoint: ext.endpoint, data: data) + } + return nil + } + } + if let selectedTab = viewModel.selectedTab, + !viewModel.tabItems.isEmpty && + !viewModel.tabItems.contains(selectedTab) { + viewModel.selectedTab = viewModel.tabItems[0] + } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index 2ef8c3b94..a710b6b3a 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -15,14 +15,14 @@ struct SourceControlGeneralView: View { var body: some View { SettingsForm { - Section { + Section("Source Control") { enableSourceControl refreshLocalStatusAuto fetchRefreshStatusAuto addRemoveFilesAuto selectFilesToCommitAuto } - Section { + Section("Text Editing") { showSourceControlChanges includeUpstreamChanges } @@ -48,6 +48,7 @@ private extension SourceControlGeneralView { "Refresh local status automatically", isOn: $settings.refreshStatusLocally ) + .disabled(!settings.enableSourceControl) } private var fetchRefreshStatusAuto: some View { @@ -55,6 +56,7 @@ private extension SourceControlGeneralView { "Fetch and refresh server status automatically", isOn: $settings.fetchRefreshServerStatus ) + .disabled(!settings.enableSourceControl) } private var addRemoveFilesAuto: some View { @@ -62,6 +64,7 @@ private extension SourceControlGeneralView { "Add and remove files automatically", isOn: $settings.addRemoveAutomatically ) + .disabled(!settings.enableSourceControl) } private var selectFilesToCommitAuto: some View { @@ -69,6 +72,7 @@ private extension SourceControlGeneralView { "Select files to commit automatically", isOn: $settings.selectFilesToCommit ) + .disabled(!settings.enableSourceControl) } private var showSourceControlChanges: some View { @@ -76,6 +80,7 @@ private extension SourceControlGeneralView { "Show source control changes", isOn: $settings.showSourceControlChanges ) + .disabled(!settings.enableSourceControl) } private var includeUpstreamChanges: some View { @@ -83,6 +88,7 @@ private extension SourceControlGeneralView { "Include upstream changes", isOn: $settings.includeUpstreamChanges ) + .disabled(!settings.enableSourceControl || !settings.showSourceControlChanges) } private var comparisonView: some View { From 78b8184cc25c26b5574ac41c56e69ad9f9ba69df Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 28 Oct 2024 22:52:25 -0500 Subject: [PATCH 02/27] Hiding History Inspector when source control is disabled in Settings. --- .../Views/InspectorAreaView.swift | 47 +++++++++++-------- .../Views/NavigatorAreaView.swift | 2 +- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift index 2baa2f081..4c47109bc 100644 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift @@ -1,10 +1,3 @@ -// -// InspectorAreaView.swift -// CodeEdit -// -// Created by Austin Condiff on 3/21/22. -// - import SwiftUI struct InspectorAreaView: View { @@ -15,6 +8,9 @@ struct InspectorAreaView: View { @EnvironmentObject private var editorManager: EditorManager + @AppSettings(\.sourceControl.general.enableSourceControl) + private var enableSourceControl: Bool + @AppSettings(\.general.inspectorTabBarPosition) var sidebarPosition: SettingsData.SidebarTabBarPosition @@ -22,19 +18,7 @@ struct InspectorAreaView: View { init(viewModel: InspectorAreaViewModel) { self.viewModel = viewModel - - viewModel.tabItems = [.file, .gitHistory] - viewModel.tabItems += extensionManager - .extensions - .map { ext in - ext.availableFeatures.compactMap { - if case .sidebarItem(let data) = $0, data.kind == .inspector { - return InspectorTab.uiExtension(endpoint: ext.endpoint, data: data) - } - return nil - } - } - .joined() + updateTabItems() } func getExtension(_ id: String) -> ExtensionInfo? { @@ -73,5 +57,28 @@ struct InspectorAreaView: View { .formStyle(.grouped) .accessibilityElement(children: .contain) .accessibilityLabel("inspector") + .onChange(of: enableSourceControl) { _ in + updateTabItems() + } + } + + private func updateTabItems() { + viewModel.tabItems = [.file] + + (enableSourceControl ? [.gitHistory] : []) + + extensionManager + .extensions + .flatMap { ext in + ext.availableFeatures.compactMap { + if case .sidebarItem(let data) = $0, data.kind == .inspector { + return InspectorTab.uiExtension(endpoint: ext.endpoint, data: data) + } + return nil + } + } + if let selectedTab = selection, + !viewModel.tabItems.isEmpty && + !viewModel.tabItems.contains(selectedTab) { + selection = viewModel.tabItems[0] + } } } diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift index 5e3d868ba..93bb7606f 100644 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift +++ b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift @@ -73,7 +73,7 @@ struct NavigatorAreaView: View { return nil } } - if let selectedTab = viewModel.selectedTab, + if let selectedTab = viewModel.selectedTab, !viewModel.tabItems.isEmpty && !viewModel.tabItems.contains(selectedTab) { viewModel.selectedTab = viewModel.tabItems[0] From e58ab760ea1051deffb016985fba9965e6dd3065 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 28 Oct 2024 22:55:59 -0500 Subject: [PATCH 03/27] Hiding Source Control menu when source control is disabled in Settings. --- CodeEdit/Features/WindowCommands/CodeEditCommands.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift index c081201a2..fd860b59f 100644 --- a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift +++ b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift @@ -8,13 +8,16 @@ import SwiftUI struct CodeEditCommands: Commands { + @AppSettings(\.sourceControl.general.enableSourceControl) + private var enableSourceControl + var body: some Commands { MainCommands() FileCommands() ViewCommands() FindCommands() NavigateCommands() - SourceControlCommands() + if enableSourceControl { SourceControlCommands() } ExtensionCommands() WindowCommands() HelpCommands() From 7adc787eea7f6279498c568ca9d06374684e5814 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 29 Oct 2024 09:52:54 -0500 Subject: [PATCH 04/27] If enableSourceControl or refreshStatusLocally setting is false then we disable file events on the .git folder. Enabling git settings by default. --- .../CEWorkspaceFileManager+DirectoryEvents.swift | 7 +++++-- .../Models/SourceControlSettings.swift | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index fd1482267..618262008 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -30,7 +30,7 @@ extension CEWorkspaceFileManager { // Can be ignored for now, these I think not related to tree changes continue case .rootChanged: - // TODO: Handle workspace root changing. + // TODO: #1880 - Handle workspace root changing. continue case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed: for fileItem in fileItems { @@ -48,7 +48,10 @@ extension CEWorkspaceFileManager { self.notifyObservers(updatedItems: files) } - self.handleGitEvents(events: events) + if Settings.shared.preferences.sourceControl.general.enableSourceControl && + Settings.shared.preferences.sourceControl.general.refreshStatusLocally { + self.handleGitEvents(events: events) + } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index 097825782..e6faecd65 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -54,17 +54,17 @@ extension SettingsData { /// Indicates whether or not the source control is active var enableSourceControl: Bool = true /// Indicates whether or not we should include the upstream changes - var refreshStatusLocally: Bool = false + var refreshStatusLocally: Bool = true /// Indicates whether or not we should include the upstream changes - var fetchRefreshServerStatus: Bool = false + var fetchRefreshServerStatus: Bool = true /// Indicates whether or not we should include the upstream changes - var addRemoveAutomatically: Bool = false + var addRemoveAutomatically: Bool = true /// Indicates whether or not we should include the upstream changes - var selectFilesToCommit: Bool = false + var selectFilesToCommit: Bool = true /// Indicates whether or not to show the source control changes var showSourceControlChanges: Bool = true /// Indicates whether or not we should include the upstream - var includeUpstreamChanges: Bool = false + var includeUpstreamChanges: Bool = true /// Indicates whether or not we should open the reported feedback in the browser var openFeedbackInBrowser: Bool = true /// The selected value of the comparison view From 5eb0f7f5bf541b851c25d5a4d72989945cb97a6b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 29 Oct 2024 13:47:08 -0500 Subject: [PATCH 05/27] Hiding branch picker in the toolbar when source control is disabled in Settings by clearing the current branch stored in the source control manager. --- .../CodeEditUI/Views/ToolbarBranchPicker.swift | 12 ++++++++---- CodeEdit/WorkspaceView.swift | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift index db50b1b63..193e4d98b 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift +++ b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift @@ -71,8 +71,10 @@ struct ToolbarBranchPicker: View { isHovering = active } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { (_) in - Task { - await sourceControlManager?.refreshCurrentBranch() + if self.currentBranch != nil { + Task { + await sourceControlManager?.refreshCurrentBranch() + } } } .onReceive( @@ -82,8 +84,10 @@ struct ToolbarBranchPicker: View { self.currentBranch = branch } .task { - await self.sourceControlManager?.refreshCurrentBranch() - await self.sourceControlManager?.refreshBranches() + if Settings.shared.preferences.sourceControl.general.enableSourceControl { + await self.sourceControlManager?.refreshCurrentBranch() + await self.sourceControlManager?.refreshBranches() + } } } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 8e3c9f224..c2a15162c 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -19,6 +19,9 @@ struct WorkspaceView: View { @AppSettings(\.theme.matchAppearance) var matchAppearance + @AppSettings(\.sourceControl.general.enableSourceControl) + var enableSourceControl + @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel @@ -130,6 +133,15 @@ struct WorkspaceView: View { : themeModel.selectedLightTheme } } + .onChange(of: enableSourceControl) { newValue in + if !newValue { + Task { + await sourceControlManager.refreshCurrentBranch() + } + } else { + sourceControlManager.currentBranch = nil + } + } .onChange(of: focusedEditor) { newValue in /// Update active tab group only if the new one is not the same with it. if let newValue, editorManager.activeEditor != newValue { From b7c9797a2a38d77391327b9bb07a5dc7f1fafad9 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 30 Oct 2024 21:58:46 -0500 Subject: [PATCH 06/27] Enabled fetch and refresh server status automatically setting. When disabled automatic fetching is disabled. --- .../Views/SourceControlNavigatorView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift index 31660395b..31363fc91 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/Views/SourceControlNavigatorView.swift @@ -10,6 +10,9 @@ import SwiftUI struct SourceControlNavigatorView: View { @EnvironmentObject private var workspace: WorkspaceDocument + @AppSettings(\.sourceControl.general.fetchRefreshServerStatus) + var fetchRefreshServerStatus + var body: some View { if let sourceControlManager = workspace.workspaceFileManager?.sourceControlManager { VStack(spacing: 0) { @@ -18,7 +21,9 @@ struct SourceControlNavigatorView: View { .task { do { while true { - try await sourceControlManager.fetch() + if fetchRefreshServerStatus { + try await sourceControlManager.fetch() + } try await Task.sleep(for: .seconds(10)) } } catch { From 409c4ce728c0079147710b40962320dafcaa4786 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 1 Nov 2024 14:08:41 -0500 Subject: [PATCH 07/27] Author name, author email, and prefer rebase when pulling settings use the users global git config instead of CodeEdit settings. Introduced Limiter.debounce and Limiter.throttle. --- CodeEdit.xcodeproj/project.pbxproj | 8 ++ .../Models/SourceControlSettings.swift | 13 --- .../SourceControlGitView.swift | 51 ++++++++++- .../SourceControl/Client/GitClient+Pull.swift | 6 +- .../SourceControl/Client/GitClient.swift | 11 +++ .../Client/GitConfigClient.swift | 85 +++++++++++++++++++ .../Views/SourceControlPullView.swift | 8 ++ CodeEdit/Utils/Limiter.swift | 50 +++++++++++ 8 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 CodeEdit/Features/SourceControl/Client/GitConfigClient.swift create mode 100644 CodeEdit/Utils/Limiter.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0d697ab9f..cb266678e 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -554,6 +554,7 @@ B67DB0F62AFC2A7A002DC647 /* FindNavigatorToolbarBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DB0F52AFC2A7A002DC647 /* FindNavigatorToolbarBottom.swift */; }; B67DB0F92AFDF638002DC647 /* IconButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */; }; B67DB0FC2AFDF71F002DC647 /* IconToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */; }; + B67DBB882CD51C55007F4F18 /* Limiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB872CD51C51007F4F18 /* Limiter.swift */; }; B68108042C60287F008B27C1 /* StartTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */; }; B685DE7929CC9CCD002860C8 /* StatusBarIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */; }; B6966A282C2F683300259C2D /* SourceControlPullView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A272C2F683300259C2D /* SourceControlPullView.swift */; }; @@ -585,6 +586,7 @@ B6CFD8112C20A8EE00E63F1A /* NSFont+WithWeight.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */; }; B6D7EA592971078500301FAC /* InspectorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA582971078500301FAC /* InspectorSection.swift */; }; B6D7EA5C297107DD00301FAC /* InspectorField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA5B297107DD00301FAC /* InspectorField.swift */; }; + B6E38E022CD3E63A00F4E65A /* GitConfigClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */; }; B6E41C7029DD157F0088F9F4 /* AccountsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */; }; B6E41C7429DD40010088F9F4 /* View+HideSidebarToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */; }; B6E41C7929DE02800088F9F4 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */; }; @@ -1217,6 +1219,7 @@ B67DB0F52AFC2A7A002DC647 /* FindNavigatorToolbarBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorToolbarBottom.swift; sourceTree = ""; }; B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButtonStyle.swift; sourceTree = ""; }; B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconToggleStyle.swift; sourceTree = ""; }; + B67DBB872CD51C51007F4F18 /* Limiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Limiter.swift; sourceTree = ""; }; B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTaskToolbarButton.swift; sourceTree = ""; }; B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarIcon.swift; sourceTree = ""; }; B6966A272C2F683300259C2D /* SourceControlPullView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlPullView.swift; sourceTree = ""; }; @@ -1248,6 +1251,7 @@ B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSFont+WithWeight.swift"; sourceTree = ""; }; B6D7EA582971078500301FAC /* InspectorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorSection.swift; sourceTree = ""; }; B6D7EA5B297107DD00301FAC /* InspectorField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorField.swift; sourceTree = ""; }; + B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitConfigClient.swift; sourceTree = ""; }; B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsView.swift; sourceTree = ""; }; B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+HideSidebarToggle.swift"; sourceTree = ""; }; B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = ""; }; @@ -2252,6 +2256,7 @@ isa = PBXGroup; children = ( 58A5DF7F29325B5A00D1BD5D /* GitClient.swift */, + B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */, 04BA7C182AE2D7C600584E1C /* GitClient+Branches.swift */, 04BA7C1D2AE2D8A000584E1C /* GitClient+Clone.swift */, 04BA7C1B2AE2D84100584E1C /* GitClient+Commit.swift */, @@ -2435,6 +2440,7 @@ 5831E3C92933E83400D5A6D2 /* Protocols */, 5875680E29316BDC00C965A3 /* ShellClient */, 6C5C891A2A3F736500A94FE1 /* FocusedValues.swift */, + B67DBB872CD51C51007F4F18 /* Limiter.swift */, ); path = Utils; sourceTree = ""; @@ -4025,6 +4031,7 @@ 58798233292E30B90085B254 /* FeedbackToolbar.swift in Sources */, 6CC17B5B2C44258700834E2C /* WindowControllerPropertyWrapper.swift in Sources */, 587B9E6829301D8F00AC7927 /* GitLabAccountModel.swift in Sources */, + B67DBB882CD51C55007F4F18 /* Limiter.swift in Sources */, 5878DAA7291AE76700DD95A3 /* OpenQuicklyViewModel.swift in Sources */, 6CFF967429BEBCC300182D6F /* FindCommands.swift in Sources */, 587B9E6529301D8F00AC7927 /* GitLabGroupAccess.swift in Sources */, @@ -4241,6 +4248,7 @@ 581550D429FBD37D00684881 /* ProjectNavigatorToolbarBottom.swift in Sources */, 66AF6CE72BF17FFB00D83C9D /* UpdateStatusBarInfo.swift in Sources */, 587B9E7E29301D8F00AC7927 /* GitHubGistRouter.swift in Sources */, + B6E38E022CD3E63A00F4E65A /* GitConfigClient.swift in Sources */, B6AB09A52AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift in Sources */, 04BA7C0B2AE2A2D100584E1C /* GitBranch.swift in Sources */, 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index e6faecd65..45b687654 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -130,31 +130,18 @@ extension SettingsData { } struct SourceControlGit: Codable, Hashable { - /// The author name - var authorName: String = "" - /// The author email - var authorEmail: String = "" - /// Indicates what files should be ignored when committing var ignoredFiles: [IgnoredFiles] = [] /// Indicates whether we should rebase when pulling commits - var preferRebaseWhenPulling: Bool = false - /// Indicates whether we should show commits per file log var showMergeCommitsPerFileLog: Bool = false /// Default initializer init() {} /// Explicit decoder init for setting default values when key is not present in `JSON` init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.authorName = try container.decodeIfPresent(String.self, forKey: .authorName) ?? "" - self.authorEmail = try container.decodeIfPresent(String.self, forKey: .authorEmail) ?? "" self.ignoredFiles = try container.decodeIfPresent( [IgnoredFiles].self, forKey: .ignoredFiles ) ?? [] - self.preferRebaseWhenPulling = try container.decodeIfPresent( - Bool.self, - forKey: .preferRebaseWhenPulling - ) ?? false self.showMergeCommitsPerFileLog = try container.decodeIfPresent( Bool.self, forKey: .showMergeCommitsPerFileLog diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 504da5766..7f325897c 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -11,7 +11,12 @@ struct SourceControlGitView: View { @AppSettings(\.sourceControl.git) var git - @State var ignoredFileSelection: IgnoredFiles.ID? + let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + + @State private var authorName: String = "" + @State private var authorEmail: String = "" + @State private var preferRebaseWhenPulling: Bool = false + @State private var hasAppeared = false var body: some View { SettingsForm { @@ -24,23 +29,61 @@ struct SourceControlGitView: View { showMergeCommitsInPerFileLog } } + .onAppear { + Task { + authorName = try await gitConfig.get(key: "user.name", global: true) ?? "" + authorEmail = try await gitConfig.get(key: "user.email", global: true) ?? "" + preferRebaseWhenPulling = try await gitConfig.get(key: "pull.rebase", global: true) ?? false + Task { + hasAppeared = true + } + } + } } } private extension SourceControlGitView { private var gitAuthorName: some View { - TextField("Author Name", text: $git.authorName) + TextField("Author Name", text: $authorName) + .onChange(of: authorName) { newValue in + if hasAppeared { + Limiter.debounce(id: "authorNameDebouncer", duration: 0.5) { + Task { + await gitConfig.set(key: "user.name", value: newValue, global: true) + } + } + } + } } private var gitEmail: some View { - TextField("Author Email", text: $git.authorEmail) + TextField("Author Email", text: $authorEmail) + .onChange(of: authorEmail) { newValue in + if hasAppeared { + Limiter.debounce(id: "authorEmailDebouncer", duration: 0.5) { + Task { + await gitConfig.set(key: "user.email", value: newValue, global: true) + } + } + } + } } private var preferToRebaseWhenPulling: some View { Toggle( "Prefer to rebase when pulling", - isOn: $git.preferRebaseWhenPulling + isOn: $preferRebaseWhenPulling ) + .onChange(of: preferRebaseWhenPulling) { newValue in + if hasAppeared { + Limiter.debounce(id: "pullRebaseDebouncer", duration: 0.5) { + Task { + print("Setting pull.rebase to \(newValue)") + await gitConfig.set(key: "pull.rebase", value: newValue, global: true) + } + } + } + } } private var showMergeCommitsInPerFileLog: some View { diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift b/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift index 3954feba7..d5f8c9ca6 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+Pull.swift @@ -10,16 +10,12 @@ import Foundation extension GitClient { /// Pull changes from remote func pullFromRemote(remote: String? = nil, branch: String? = nil, rebase: Bool = false) async throws { - var command = "pull" + var command = "pull \(rebase ? "--rebase" : "--no-rebase")" if let remote = remote, let branch = branch { command += " \(remote) \(branch)" } - if rebase { - command += " --rebase" - } - _ = try await self.run(command) } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient.swift b/CodeEdit/Features/SourceControl/Client/GitClient.swift index 6dcf210c7..f221d6a16 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient.swift @@ -38,9 +38,20 @@ class GitClient { internal let directoryURL: URL internal let shellClient: ShellClient + private let configClient: GitConfigClient + init(directoryURL: URL, shellClient: ShellClient) { self.directoryURL = directoryURL self.shellClient = shellClient + self.configClient = GitConfigClient(projectURL: directoryURL, shellClient: shellClient) + } + + func getConfig(key: String) async throws -> T? { + return try await configClient.get(key: key, global: false) + } + + func setConfig(key: String, value: T) async { + await configClient.set(key: key, value: value, global: false) } /// Runs a git command, it will prepend the command with `cd ;git`, diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift b/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift new file mode 100644 index 000000000..a0497e97d --- /dev/null +++ b/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift @@ -0,0 +1,85 @@ +// +// GitConfigClient.swift +// CodeEdit +// +// Created by Austin Condiff on 10/31/24. +// + +import Foundation + +protocol GitConfigRepresentable { + init?(configValue: String) + var asConfigValue: String { get } +} + +extension Bool: GitConfigRepresentable { + init?(configValue: String) { + switch configValue.lowercased() { + case "true": self = true + case "false": self = false + default: return nil + } + } + + var asConfigValue: String { + self ? "true" : "false" + } +} + +extension String: GitConfigRepresentable { + init?(configValue: String) { + self = configValue + } + + var asConfigValue: String { + "\"\(self)\"" + } +} + +class GitConfigClient { + private let projectURL: URL? + private let shellClient: ShellClient + + init(projectURL: URL? = nil, shellClient: ShellClient) { + self.projectURL = projectURL + self.shellClient = shellClient + } + + private func runConfigCommand(_ command: String, global: Bool) async throws -> String { + var fullCommand = "git config" + + if global { + fullCommand += " --global" + } else if let projectURL = projectURL { + fullCommand = "cd \(projectURL.relativePath.escapedWhiteSpaces()); " + fullCommand + } + + fullCommand += " \(command)" + return try shellClient.run(fullCommand) + } + + func get(key: String, global: Bool = false) async throws -> T? { + let output = try await runConfigCommand(key, global: global) + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + return T(configValue: trimmedOutput) + } + + func set(key: String, value: T, global: Bool = false) async { + let shouldUnset: Bool + if let boolValue = value as? Bool { + shouldUnset = !boolValue + } else if let stringValue = value as? String { + shouldUnset = stringValue.isEmpty + } else { + shouldUnset = false + } + + let commandString = shouldUnset ? "--unset \(key)" : "\(key) \(value.asConfigValue)" + + do { + _ = try await runConfigCommand(commandString, global: global) + } catch { + print("Failed to set \(key): \(error)") + } + } +} diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift b/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift index 678863127..193c7a45c 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift +++ b/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift @@ -15,6 +15,9 @@ struct SourceControlPullView: View { @State var loading: Bool = false + @AppSettings(\.sourceControl.git.preferRebaseWhenPulling) + var preferRebaseWhenPulling + var body: some View { VStack(spacing: 0) { Form { @@ -35,6 +38,11 @@ struct SourceControlPullView: View { .formStyle(.grouped) .scrollDisabled(true) .scrollContentBackground(.hidden) + .onAppear { + if preferRebaseWhenPulling { + sourceControlManager.operationRebase = true + } + } HStack { if loading { HStack(spacing: 7.5) { diff --git a/CodeEdit/Utils/Limiter.swift b/CodeEdit/Utils/Limiter.swift new file mode 100644 index 000000000..52613834f --- /dev/null +++ b/CodeEdit/Utils/Limiter.swift @@ -0,0 +1,50 @@ +// +// Limiter.swift +// CodeEdit +// +// Created by Austin Condiff on 11/1/24. +// + +import Combine +import Foundation + +enum Limiter { + // Keep track of debounce timers and throttle states + private static var debounceTimers: [AnyHashable: AnyCancellable] = [:] + private static var throttleLastExecution: [AnyHashable: Date] = [:] + + /// Debounces an action with a specified duration and identifier. + /// - Parameters: + /// - id: A unique identifier for the debounced action. + /// - duration: The debounce duration in seconds. + /// - action: The action to be executed after the debounce period. + static func debounce(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) { + // Cancel any existing debounce timer for the given ID + debounceTimers[id]?.cancel() + + // Start a new debounce timer for the given ID + debounceTimers[id] = Timer.publish(every: duration, on: .main, in: .common) + .autoconnect() + .first() + .sink { _ in + action() + debounceTimers[id] = nil + } + } + + /// Throttles an action with a specified duration and identifier. + /// - Parameters: + /// - id: A unique identifier for the throttled action. + /// - duration: The throttle duration in seconds. + /// - action: The action to be executed after the throttle period. + static func throttle(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) { + // Check the time of the last execution for the given ID + if let lastExecution = throttleLastExecution[id], Date().timeIntervalSince(lastExecution) < duration { + return // Skip this call if it's within the throttle duration + } + + // Update the last execution time and perform the action + throttleLastExecution[id] = Date() + action() + } +} From 7ddb5e862b20d90f2deb8bc7fcc4c53dfb433ddd Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 1 Nov 2024 14:29:59 -0500 Subject: [PATCH 08/27] Default branch setting uses the users global git config instead of CodeEdit settings. --- .../Models/SourceControlSettings.swift | 3 --- .../SourceControlGeneralView.swift | 24 +++++++++++++++++-- .../Views/SourceControlPullView.swift | 12 ++++++---- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index 45b687654..e01d5ae20 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -71,8 +71,6 @@ extension SettingsData { var revisionComparisonLayout: RevisionComparisonLayout = .localLeft /// The selected value of the control navigator var controlNavigatorOrder: ControlNavigatorOrder = .sortByName - /// The name of the default branch - var defaultBranchName: String = "main" /// Default initializer init() {} /// Explicit decoder init for setting default values when key is not present in `JSON` @@ -109,7 +107,6 @@ extension SettingsData { ControlNavigatorOrder.self, forKey: .controlNavigatorOrder ) ?? .sortByName - self.defaultBranchName = try container.decodeIfPresent(String.self, forKey: .defaultBranchName) ?? "main" } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index a710b6b3a..08535eb13 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -11,7 +11,10 @@ struct SourceControlGeneralView: View { @AppSettings(\.sourceControl.general) var settings - @State private var text: String = "main" + let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + + @State private var defaultBranch: String = "" + @State private var hasAppeared = false var body: some View { SettingsForm { @@ -32,6 +35,14 @@ struct SourceControlGeneralView: View { defaultBranchName } } + .onAppear { + Task { + defaultBranch = try await gitConfig.get(key: "init.defaultBranch", global: true) ?? "" + Task { + hasAppeared = true + } + } + } } } @@ -116,9 +127,18 @@ private extension SourceControlGeneralView { } private var defaultBranchName: some View { - TextField(text: $text) { + TextField(text: $defaultBranch) { Text("Default branch name") Text("Cannot contain spaces, backslashes, or other symbols") } + .onChange(of: defaultBranch) { newValue in + if hasAppeared { + Limiter.debounce(id: "defaultBranchDebouncer", duration: 0.5) { + Task { + await gitConfig.set(key: "init.defaultBranch", value: newValue, global: true) + } + } + } + } } } diff --git a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift b/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift index 193c7a45c..45d0a8f97 100644 --- a/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift +++ b/CodeEdit/Features/SourceControl/Views/SourceControlPullView.swift @@ -13,10 +13,11 @@ struct SourceControlPullView: View { @EnvironmentObject var sourceControlManager: SourceControlManager + let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + @State var loading: Bool = false - @AppSettings(\.sourceControl.git.preferRebaseWhenPulling) - var preferRebaseWhenPulling + @State var preferRebaseWhenPulling: Bool = false var body: some View { VStack(spacing: 0) { @@ -39,8 +40,11 @@ struct SourceControlPullView: View { .scrollDisabled(true) .scrollContentBackground(.hidden) .onAppear { - if preferRebaseWhenPulling { - sourceControlManager.operationRebase = true + Task { + preferRebaseWhenPulling = try await gitConfig.get(key: "pull.rebase", global: true) ?? false + if preferRebaseWhenPulling { + sourceControlManager.operationRebase = true + } } } HStack { From ae6ba40976c8dcf044a7b315c9eddb89f0f79188 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 1 Nov 2024 15:21:59 -0500 Subject: [PATCH 09/27] Show merge commits per file log setting now shows merge commits if enabled, if disabled no merge commits are displayed. --- .../HistoryInspector/HistoryInspectorModel.swift | 6 +++++- .../HistoryInspector/HistoryInspectorView.swift | 8 ++++++++ .../Views/SourceControlNavigatorHistoryView.swift | 13 ++++++++++++- .../Client/GitClient+CommitHistory.swift | 5 +++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift index 728a3d6c0..bb5ff4b8b 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorModel.swift @@ -40,7 +40,11 @@ final class HistoryInspectorModel: ObservableObject { do { let commitHistory = try await sourceControlManager .gitClient - .getCommitHistory(maxCount: 40, fileLocalPath: fileURL) + .getCommitHistory( + maxCount: 40, + fileLocalPath: fileURL, + showMergeCommits: Settings.shared.preferences.sourceControl.git.showMergeCommitsPerFileLog + ) await setCommitHistory(commitHistory) } catch { await setCommitHistory([]) diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift index 9bc871f50..280c00ffb 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorView.swift @@ -7,6 +7,9 @@ import SwiftUI struct HistoryInspectorView: View { + @AppSettings(\.sourceControl.git.showMergeCommitsPerFileLog) + var showMergeCommitsPerFileLog + @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var editorManager: EditorManager @@ -60,5 +63,10 @@ struct HistoryInspectorView: View { await model.setWorkspace(sourceControlManager: workspace.sourceControlManager) await model.setFile(url: editorManager.activeEditor.selectedTab?.file.url.path) } + .onChange(of: showMergeCommitsPerFileLog) { _ in + Task { + await model.updateCommitHistory() + } + } } } diff --git a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift index c3a429923..b615763aa 100644 --- a/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift +++ b/CodeEdit/Features/NavigatorArea/SourceControlNavigator/History/Views/SourceControlNavigatorHistoryView.swift @@ -15,6 +15,9 @@ struct SourceControlNavigatorHistoryView: View { case error(error: Error) } + @AppSettings(\.sourceControl.git.showMergeCommitsPerFileLog) + var showMergeCommitsPerFileLog + @EnvironmentObject var sourceControlManager: SourceControlManager @State var commitHistoryStatus: Status = .loading @@ -28,7 +31,10 @@ struct SourceControlNavigatorHistoryView: View { commitHistoryStatus = .loading let commits = try await sourceControlManager .gitClient - .getCommitHistory(branchName: sourceControlManager.currentBranch?.name) + .getCommitHistory( + branchName: sourceControlManager.currentBranch?.name, + showMergeCommits: Settings.shared.preferences.sourceControl.git.showMergeCommitsPerFileLog + ) await MainActor.run { commitHistory = commits commitHistoryStatus = .ready @@ -102,5 +108,10 @@ struct SourceControlNavigatorHistoryView: View { .task { await updateCommitHistory() } + .onChange(of: showMergeCommitsPerFileLog) { _ in + Task { + await updateCommitHistory() + } + } } } diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift index 574e65034..42c494106 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift @@ -17,7 +17,8 @@ extension GitClient { func getCommitHistory( branchName: String? = nil, maxCount: Int? = nil, - fileLocalPath: String? = nil + fileLocalPath: String? = nil, + showMergeCommits: Bool = false ) async throws -> [GitCommit] { let branchString = branchName != nil ? "\"\(branchName ?? "")\"" : "" let fileString = fileLocalPath != nil ? "\"\(fileLocalPath ?? "")\"" : "" @@ -30,7 +31,7 @@ extension GitClient { dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" let output = try await run( - "log -z --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \(countString) \(branchString) -- \(fileString)" + "log \(showMergeCommits ? "" : "--no-merges") -z --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \(countString) \(branchString) -- \(fileString)" .trimmingCharacters(in: .whitespacesAndNewlines) ) let remoteURL = try await getRemoteURL() From 241ebbd14174fa69a3faef6a33ea3de24bc8c955 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 1 Nov 2024 15:27:28 -0500 Subject: [PATCH 10/27] Fixed swiftlint error --- .../SourceControl/Client/GitClient+CommitHistory.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift index 42c494106..6245fe773 100644 --- a/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift +++ b/CodeEdit/Features/SourceControl/Client/GitClient+CommitHistory.swift @@ -31,8 +31,11 @@ extension GitClient { dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" let output = try await run( - "log \(showMergeCommits ? "" : "--no-merges") -z --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \(countString) \(branchString) -- \(fileString)" - .trimmingCharacters(in: .whitespacesAndNewlines) + """ + log \(showMergeCommits ? "" : "--no-merges") -z \ + --pretty=%h¦%H¦%s¦%aN¦%ae¦%cn¦%ce¦%aD¦%b¦%D¦ \ + \(countString) \(branchString) -- \(fileString) + """.trimmingCharacters(in: .whitespacesAndNewlines) ) let remoteURL = try await getRemoteURL() From 996be19d75b0530832e39371f4ddfb137d6f4e2c Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 2 Nov 2024 01:23:48 -0500 Subject: [PATCH 11/27] Added Ignored Files list in source control settings. Refactored search excluded files list and ignored files list to use new shared glob list view. --- CodeEdit.xcodeproj/project.pbxproj | 28 +++-- .../Settings/Models/GlobPattern.swift | 16 +++ .../Models/SearchSettingsModel.swift | 21 ++-- .../SearchSettings/SearchSettingsView.swift | 108 ++---------------- .../IgnoredFilesListView.swift | 22 ++++ .../Models/IgnorePatternModel.swift | 63 ++++++++++ .../Models/IgnoredFiles.swift | 13 --- .../Models/SourceControlSettings.swift | 5 - .../SourceControlGitView.swift | 5 + .../Settings/Views/GlobPatternList.swift | 87 ++++++++++++++ .../GlobPatternListItem.swift} | 24 ++-- 11 files changed, 246 insertions(+), 146 deletions(-) create mode 100644 CodeEdit/Features/Settings/Models/GlobPattern.swift create mode 100644 CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift create mode 100644 CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift delete mode 100644 CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnoredFiles.swift create mode 100644 CodeEdit/Features/Settings/Views/GlobPatternList.swift rename CodeEdit/Features/Settings/{Pages/SearchSettings/IgnorePatternListItemView.swift => Views/GlobPatternListItem.swift} (70%) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index cb266678e..ec39a910d 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -259,7 +259,6 @@ 58D01C9A293167DC00C5B6B4 /* CodeEditKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D01C90293167DC00C5B6B4 /* CodeEditKeychain.swift */; }; 58D01C9B293167DC00C5B6B4 /* CodeEditKeychainConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D01C91293167DC00C5B6B4 /* CodeEditKeychainConstants.swift */; }; 58D01C9D293167DC00C5B6B4 /* KeychainSwiftAccessOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D01C93293167DC00C5B6B4 /* KeychainSwiftAccessOptions.swift */; }; - 58F2EAEC292FB2B0004A9BDE /* IgnoredFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EAAA292FB2B0004A9BDE /* IgnoredFiles.swift */; }; 58F2EAEF292FB2B0004A9BDE /* ThemeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EAAF292FB2B0004A9BDE /* ThemeSettingsView.swift */; }; 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EACE292FB2B0004A9BDE /* Documentation.docc */; }; 58F2EB04292FB2B0004A9BDE /* SourceControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EAD1292FB2B0004A9BDE /* SourceControlSettings.swift */; }; @@ -276,7 +275,6 @@ 58FD7608291EA1CB0051D6E4 /* QuickActionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD7605291EA1CB0051D6E4 /* QuickActionsViewModel.swift */; }; 58FD7609291EA1CB0051D6E4 /* QuickActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD7607291EA1CB0051D6E4 /* QuickActionsView.swift */; }; 5994B6DA2BD6B408006A4C5F /* Editor+TabSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5994B6D92BD6B408006A4C5F /* Editor+TabSwitch.swift */; }; - 5B241BF32B6DDBFF0016E616 /* IgnorePatternListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B241BF22B6DDBFF0016E616 /* IgnorePatternListItemView.swift */; }; 5B698A0A2B262FA000DE9392 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B698A092B262FA000DE9392 /* SearchSettingsView.swift */; }; 5B698A0D2B26327800DE9392 /* SearchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B698A0C2B26327800DE9392 /* SearchSettings.swift */; }; 5B698A0F2B2636A700DE9392 /* SearchSettingsIgnoreGlobPatternItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B698A0E2B2636A700DE9392 /* SearchSettingsIgnoreGlobPatternItemView.swift */; }; @@ -555,6 +553,11 @@ B67DB0F92AFDF638002DC647 /* IconButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */; }; B67DB0FC2AFDF71F002DC647 /* IconToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */; }; B67DBB882CD51C55007F4F18 /* Limiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB872CD51C51007F4F18 /* Limiter.swift */; }; + B67DBB8A2CD5D8F7007F4F18 /* IgnoredFilesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB892CD5D8E4007F4F18 /* IgnoredFilesListView.swift */; }; + B67DBB8C2CD5D9CB007F4F18 /* IgnorePatternModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB8B2CD5D9B4007F4F18 /* IgnorePatternModel.swift */; }; + B67DBB902CD5EA77007F4F18 /* GlobPattern.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB8F2CD5EA71007F4F18 /* GlobPattern.swift */; }; + B67DBB922CD5EAAB007F4F18 /* GlobPatternList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */; }; + B67DBB942CD5FC08007F4F18 /* GlobPatternListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */; }; B68108042C60287F008B27C1 /* StartTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */; }; B685DE7929CC9CCD002860C8 /* StatusBarIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */; }; B6966A282C2F683300259C2D /* SourceControlPullView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A272C2F683300259C2D /* SourceControlPullView.swift */; }; @@ -939,7 +942,6 @@ 58D01C90293167DC00C5B6B4 /* CodeEditKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeEditKeychain.swift; sourceTree = ""; }; 58D01C91293167DC00C5B6B4 /* CodeEditKeychainConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeEditKeychainConstants.swift; sourceTree = ""; }; 58D01C93293167DC00C5B6B4 /* KeychainSwiftAccessOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainSwiftAccessOptions.swift; sourceTree = ""; }; - 58F2EAAA292FB2B0004A9BDE /* IgnoredFiles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IgnoredFiles.swift; sourceTree = ""; }; 58F2EAAF292FB2B0004A9BDE /* ThemeSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeSettingsView.swift; sourceTree = ""; }; 58F2EACE292FB2B0004A9BDE /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; 58F2EAD1292FB2B0004A9BDE /* SourceControlSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SourceControlSettings.swift; sourceTree = ""; }; @@ -955,7 +957,6 @@ 58FD7605291EA1CB0051D6E4 /* QuickActionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickActionsViewModel.swift; sourceTree = ""; }; 58FD7607291EA1CB0051D6E4 /* QuickActionsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickActionsView.swift; sourceTree = ""; }; 5994B6D92BD6B408006A4C5F /* Editor+TabSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Editor+TabSwitch.swift"; sourceTree = ""; }; - 5B241BF22B6DDBFF0016E616 /* IgnorePatternListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnorePatternListItemView.swift; sourceTree = ""; }; 5B698A092B262FA000DE9392 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; 5B698A0C2B26327800DE9392 /* SearchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettings.swift; sourceTree = ""; }; 5B698A0E2B2636A700DE9392 /* SearchSettingsIgnoreGlobPatternItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsIgnoreGlobPatternItemView.swift; sourceTree = ""; }; @@ -1220,6 +1221,11 @@ B67DB0F82AFDF638002DC647 /* IconButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButtonStyle.swift; sourceTree = ""; }; B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconToggleStyle.swift; sourceTree = ""; }; B67DBB872CD51C51007F4F18 /* Limiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Limiter.swift; sourceTree = ""; }; + B67DBB892CD5D8E4007F4F18 /* IgnoredFilesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnoredFilesListView.swift; sourceTree = ""; }; + B67DBB8B2CD5D9B4007F4F18 /* IgnorePatternModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IgnorePatternModel.swift; sourceTree = ""; }; + B67DBB8F2CD5EA71007F4F18 /* GlobPattern.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobPattern.swift; sourceTree = ""; }; + B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobPatternList.swift; sourceTree = ""; }; + B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobPatternListItem.swift; sourceTree = ""; }; B68108032C60287F008B27C1 /* StartTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTaskToolbarButton.swift; sourceTree = ""; }; B685DE7829CC9CCD002860C8 /* StatusBarIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarIcon.swift; sourceTree = ""; }; B6966A272C2F683300259C2D /* SourceControlPullView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlPullView.swift; sourceTree = ""; }; @@ -2498,7 +2504,7 @@ isa = PBXGroup; children = ( 58F2EAD1292FB2B0004A9BDE /* SourceControlSettings.swift */, - 58F2EAAA292FB2B0004A9BDE /* IgnoredFiles.swift */, + B67DBB8B2CD5D9B4007F4F18 /* IgnorePatternModel.swift */, ); path = Models; sourceTree = ""; @@ -2546,7 +2552,6 @@ children = ( 5B698A0B2B26326000DE9392 /* Models */, 5B698A092B262FA000DE9392 /* SearchSettingsView.swift */, - 5B241BF22B6DDBFF0016E616 /* IgnorePatternListItemView.swift */, 5B698A0E2B2636A700DE9392 /* SearchSettingsIgnoreGlobPatternItemView.swift */, ); path = SearchSettings; @@ -3185,6 +3190,7 @@ B640A9A329E218E300715F20 /* Models */ = { isa = PBXGroup; children = ( + B67DBB8F2CD5EA71007F4F18 /* GlobPattern.swift */, 58F2EADB292FB2B0004A9BDE /* SettingsData.swift */, 58F2EAD2292FB2B0004A9BDE /* Settings.swift */, 6C5FDF7929E6160000BC08C0 /* AppSettings.swift */, @@ -3521,6 +3527,8 @@ B6CF632A29E5436C0085880A /* Views */ = { isa = PBXGroup; children = ( + B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */, + B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */, B6041F4C29D7A4E9000F3454 /* SettingsPageView.swift */, B640A99D29E2184700715F20 /* SettingsForm.swift */, B6EA1FFF29DB7966001BF195 /* SettingsColorPicker.swift */, @@ -3595,6 +3603,7 @@ B6F0517A29D9E46400D72287 /* SourceControlSettingsView.swift */, B6F0517629D9E3AD00D72287 /* SourceControlGeneralView.swift */, B6F0517829D9E3C900D72287 /* SourceControlGitView.swift */, + B67DBB892CD5D8E4007F4F18 /* IgnoredFilesListView.swift */, ); path = SourceControlSettings; sourceTree = ""; @@ -4097,6 +4106,7 @@ 30B088112C0D53080063A882 /* LanguageServer+SignatureHelp.swift in Sources */, 6C578D8929CD36E400DC73B2 /* Commands+ForEach.swift in Sources */, 611192082B08CCFD00D4459B /* SearchIndexer+Terms.swift in Sources */, + B67DBB902CD5EA77007F4F18 /* GlobPattern.swift in Sources */, 28B8F884280FFE4600596236 /* NSTableView+Background.swift in Sources */, 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */, 58F2EB06292FB2B0004A9BDE /* KeybindingsSettings.swift in Sources */, @@ -4123,6 +4133,7 @@ 6C5C891B2A3F736500A94FE1 /* FocusedValues.swift in Sources */, 611192062B08CCF600D4459B /* SearchIndexer+Add.swift in Sources */, B62423302C21EE280096668B /* ThemeModel+CRUD.swift in Sources */, + B67DBB942CD5FC08007F4F18 /* GlobPatternListItem.swift in Sources */, B62AEDD72A27B3D0009A9F52 /* UtilityAreaTabViewModel.swift in Sources */, 85773E1E2A3E0A1F00C5D926 /* SettingsSearchResult.swift in Sources */, B66A4E4F29C917B8004573B4 /* WelcomeWindow.swift in Sources */, @@ -4149,7 +4160,9 @@ 587B9E7B29301D8F00AC7927 /* GitHubRouter.swift in Sources */, B68108042C60287F008B27C1 /* StartTaskToolbarButton.swift in Sources */, 201169E22837B3D800F92B46 /* SourceControlNavigatorChangesView.swift in Sources */, + B67DBB922CD5EAAB007F4F18 /* GlobPatternList.swift in Sources */, 850C631029D6B01D00E1444C /* SettingsView.swift in Sources */, + B67DBB8A2CD5D8F7007F4F18 /* IgnoredFilesListView.swift in Sources */, 77A01E6D2BC3EA2A00F0EA38 /* NSWindow+Child.swift in Sources */, 77EF6C0D2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift in Sources */, 581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */, @@ -4160,6 +4173,7 @@ 587B9E5C29301D8F00AC7927 /* Parameters.swift in Sources */, 61538B932B11201900A88846 /* String+Character.swift in Sources */, 613DF55E2B08DD5D00E9D902 /* FileHelper.swift in Sources */, + B67DBB8C2CD5D9CB007F4F18 /* IgnorePatternModel.swift in Sources */, 58798235292E30B90085B254 /* FeedbackModel.swift in Sources */, 04C3255C2801F86900C8DA2D /* ProjectNavigatorMenu.swift in Sources */, 6CC17B512C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift in Sources */, @@ -4399,7 +4413,6 @@ 04BC1CDE2AD9B4B000A83EA5 /* EditorFileTabCloseButton.swift in Sources */, 6C6BD70129CD172700235D17 /* ExtensionsListView.swift in Sources */, 043C321627E3201F006AE443 /* WorkspaceDocument.swift in Sources */, - 58F2EAEC292FB2B0004A9BDE /* IgnoredFiles.swift in Sources */, 6CD03B6A29FC773F001BD1D0 /* SettingsInjector.swift in Sources */, 58798236292E30B90085B254 /* FeedbackType.swift in Sources */, 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */, @@ -4441,7 +4454,6 @@ B69D3EE52C5F54B3005CF43A /* TasksPopoverMenuItem.swift in Sources */, 669A50532C380C8E00304CD8 /* Collection+subscript_safe.swift in Sources */, 6C08249E2C55768400A0751E /* UtilityAreaTerminal.swift in Sources */, - 5B241BF32B6DDBFF0016E616 /* IgnorePatternListItemView.swift in Sources */, 6CB52DC92AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift in Sources */, 58F2EB0B292FB2B0004A9BDE /* AccountsSettings.swift in Sources */, 5882252A292C280D00E83CDE /* StatusBarToggleUtilityAreaButton.swift in Sources */, diff --git a/CodeEdit/Features/Settings/Models/GlobPattern.swift b/CodeEdit/Features/Settings/Models/GlobPattern.swift new file mode 100644 index 000000000..c843b79a6 --- /dev/null +++ b/CodeEdit/Features/Settings/Models/GlobPattern.swift @@ -0,0 +1,16 @@ +// +// GlobPattern.swift +// CodeEdit +// +// Created by Austin Condiff on 11/2/24. +// + +import Foundation + +struct GlobPattern: Identifiable, Hashable, Decodable, Encodable { + /// Ephimeral UUID used to track its representation in the UI + var id = UUID() + + /// The Glob Pattern to render + var value: String +} diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift b/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift index 9bd6cd831..f15d0b941 100644 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift +++ b/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift @@ -7,14 +7,6 @@ import SwiftUI -struct GlobPattern: Identifiable, Hashable, Decodable, Encodable { - /// Ephimeral UUID used to track its representation in the UI - var id = UUID() - - /// The Glob Pattern to render - var value: String -} - /// The Search Settings View Model. Accessible via the singleton "``SearchSettings/shared``". /// /// **Usage:** @@ -55,6 +47,9 @@ final class SearchSettingsModel: ObservableObject { baseURL.appendingPathComponent("settings.json", isDirectory: true) } + /// Selected patterns + @Published var selection: Set = [] + /// Stores the new values from the Search Settings Model into the settings.json whenever /// `ignoreGlobPatterns` is updated @Published var ignoreGlobPatterns: [GlobPattern] { @@ -64,4 +59,14 @@ final class SearchSettingsModel: ObservableObject { } } } + + func addPattern() { + ignoreGlobPatterns.append(GlobPattern(value: "")) + } + + func removePatterns(_ selection: Set? = nil) { + let patternsToRemove = selection ?? self.selection + ignoreGlobPatterns.removeAll { patternsToRemove.contains($0) } + self.selection.removeAll() + } } diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift b/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift index cebc9f65c..b5c6ceb6f 100644 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/SearchSettings/SearchSettingsView.swift @@ -24,109 +24,15 @@ struct SearchSettingsView: View { } struct ExcludedGlobPatternList: View { - @ObservedObject private var searchSettingsModel: SearchSettingsModel = .shared - - @FocusState private var focusedField: String? - - @State private var selection: GlobPattern? + @ObservedObject private var model: SearchSettingsModel = .shared var body: some View { - List(selection: $selection) { - ForEach( - Array(searchSettingsModel.ignoreGlobPatterns.enumerated()), - id: \.element - ) { index, ignorePattern in - IgnorePatternListItem( - pattern: $searchSettingsModel.ignoreGlobPatterns[index], - selectedPattern: $selection, - addPattern: addPattern, - removePattern: removePattern, - focusedField: $focusedField, - isLast: searchSettingsModel.ignoreGlobPatterns.count == index+1 - ) - .onAppear { - if ignorePattern.value.isEmpty { - focusedField = ignorePattern.id.uuidString - } - } - } - .onMove { fromOffsets, toOffset in - searchSettingsModel.ignoreGlobPatterns.move(fromOffsets: fromOffsets, toOffset: toOffset) - } - } - .frame(minHeight: 96) - .contextMenu( - forSelectionType: GlobPattern.self, - menu: { selection in - if let pattern = selection.first { - Button("Edit") { - focusedField = pattern.id.uuidString - } - Button("Add") { - addPattern() - } - Divider() - Button("Remove") { - if !searchSettingsModel.ignoreGlobPatterns.isEmpty { - removePattern(pattern) - } - } - } - }, - primaryAction: { selection in - if let pattern = selection.first { - focusedField = pattern.id.uuidString - } - } + GlobPatternList( + patterns: $model.ignoreGlobPatterns, + selection: $model.selection, + addPattern: model.addPattern, + removePatterns: model.removePatterns, + emptyMessage: "No excluded glob patterns" ) - .overlay { - if searchSettingsModel.ignoreGlobPatterns.isEmpty { - Text("No excluded glob patterns") - .foregroundStyle(Color(.secondaryLabelColor)) - } - } - .actionBar { - Button { - addPattern() - } label: { - Image(systemName: "plus") - } - Divider() - Button { - if let pattern = selection { - removePattern(pattern) - } - } label: { - Image(systemName: "minus") - } - .disabled(selection == nil) - } - .onDeleteCommand { - removePattern(selection) - } - } - - func addPattern() { - searchSettingsModel.ignoreGlobPatterns.append(GlobPattern(value: "")) - } - - func removePattern(_ pattern: GlobPattern?) { - let selectedIndex = searchSettingsModel.ignoreGlobPatterns.firstIndex { - $0 == selection - } - - let removeIndex = searchSettingsModel.ignoreGlobPatterns.firstIndex { - $0 == selection - } - - searchSettingsModel.ignoreGlobPatterns.removeAll { - pattern == $0 - } - - if selectedIndex == removeIndex && !searchSettingsModel.ignoreGlobPatterns.isEmpty && selectedIndex != nil { - selection = searchSettingsModel.ignoreGlobPatterns[ - selectedIndex == 0 ? 0 : (selectedIndex ?? 1) - 1 - ] - } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift new file mode 100644 index 000000000..42e50942a --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift @@ -0,0 +1,22 @@ +// +// IgnoredFilesListView.swift +// CodeEdit +// +// Created by Austin Condiff on 11/1/24. +// + +import SwiftUI + +struct IgnoredFilesListView: View { + @ObservedObject private var model = IgnorePatternModel() + + var body: some View { + GlobPatternList( + patterns: $model.patterns, + selection: $model.selection, + addPattern: model.addPattern, + removePatterns: model.removePatterns, + emptyMessage: "No ignored files" + ) + } +} diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift new file mode 100644 index 000000000..36078a40a --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -0,0 +1,63 @@ +// +// IgnorePatternModel.swift +// CodeEdit +// +// Created by Austin Condiff on 11/1/24. +// + +import Foundation + +class IgnorePatternModel: ObservableObject { + @Published var patterns: [GlobPattern] = [] + @Published var selection: Set = [] + + let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + + let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") + + init() { + loadPatterns() + } + + func loadPatterns() { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + patterns = [] + return + } + + if let content = try? String(contentsOf: fileURL) { + patterns = content.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !$0.starts(with: "#") } + .map { GlobPattern(value: String($0)) } + } + } + + func savePatterns() { + let content = patterns.map(\.value).joined(separator: "\n") + try? content.write(to: fileURL, atomically: true, encoding: .utf8) + } + + func addPattern() { + if patterns.isEmpty { + Task { + await setupGlobalIgnoreFile() + } + } + patterns.append(GlobPattern(value: "")) + savePatterns() + } + + func removePatterns(_ selection: Set? = nil) { + let patternsToRemove = selection ?? self.selection + patterns.removeAll { patternsToRemove.contains($0) } + savePatterns() + self.selection.removeAll() + } + + func setupGlobalIgnoreFile() async { + guard !FileManager.default.fileExists(atPath: fileURL.path) else { return } + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + await gitConfig.set(key: "core.excludesfile", value: fileURL.path, global: true) + } +} diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnoredFiles.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnoredFiles.swift deleted file mode 100644 index 1895b91d1..000000000 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnoredFiles.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// IgnoredFiles.swift -// CodeEditModules/Settings -// -// Created by Nanashi Li on 2022/04/08. -// - -import Foundation - -struct IgnoredFiles: Codable, Identifiable, Hashable { - var id: String - var name: String -} diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index e01d5ae20..61128f9af 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -127,7 +127,6 @@ extension SettingsData { } struct SourceControlGit: Codable, Hashable { - var ignoredFiles: [IgnoredFiles] = [] /// Indicates whether we should rebase when pulling commits var showMergeCommitsPerFileLog: Bool = false /// Default initializer @@ -135,10 +134,6 @@ extension SettingsData { /// Explicit decoder init for setting default values when key is not present in `JSON` init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.ignoredFiles = try container.decodeIfPresent( - [IgnoredFiles].self, - forKey: .ignoredFiles - ) ?? [] self.showMergeCommitsPerFileLog = try container.decodeIfPresent( Bool.self, forKey: .showMergeCommitsPerFileLog diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 7f325897c..c1dab364c 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -28,6 +28,11 @@ struct SourceControlGitView: View { preferToRebaseWhenPulling showMergeCommitsInPerFileLog } + Section { + IgnoredFilesListView() + } header: { + Text("Ignored Files") + } } .onAppear { Task { diff --git a/CodeEdit/Features/Settings/Views/GlobPatternList.swift b/CodeEdit/Features/Settings/Views/GlobPatternList.swift new file mode 100644 index 000000000..704c21052 --- /dev/null +++ b/CodeEdit/Features/Settings/Views/GlobPatternList.swift @@ -0,0 +1,87 @@ +// +// GlobPatternList.swift +// CodeEdit +// +// Created by Austin Condiff on 11/2/24. +// + +import SwiftUI + +struct GlobPatternList: View { + @Binding var patterns: [GlobPattern] + let selection: Binding> + let addPattern: () -> Void + let removePatterns: (_ selection: Set?) -> Void + let emptyMessage: String + + @FocusState private var focusedField: String? + + var body: some View { + List(selection: selection) { + ForEach(Array(patterns.enumerated()), id: \.element) { index, pattern in + GlobPatternListItem( + pattern: $patterns[index], + selection: selection, + addPattern: addPattern, + removePatterns: removePatterns, + focusedField: $focusedField, + isLast: patterns.count == index + 1 + ) + .onAppear { + if pattern.value.isEmpty { + focusedField = pattern.id.uuidString + } + } + } + .onMove { fromOffsets, toOffset in + patterns.move(fromOffsets: fromOffsets, toOffset: toOffset) + } + .onDelete { _ in + removePatterns(nil) + } + } + .frame(minHeight: 96) + .contextMenu(forSelectionType: GlobPattern.self, menu: { selection in + if let pattern = selection.first { + Button("Edit") { + focusedField = pattern.id.uuidString + } + Button("Add") { + addPattern() + } + Divider() + Button("Remove") { + if !patterns.isEmpty { + removePatterns(selection) + } + } + } + }, primaryAction: { selection in + if let pattern = selection.first { + focusedField = pattern.id.uuidString + } + }) + .overlay { + if patterns.isEmpty { + Text(emptyMessage) + .foregroundStyle(Color(.secondaryLabelColor)) + } + } + .actionBar { + Button(action: addPattern) { + Image(systemName: "plus") + } + Divider() + Button { + removePatterns(nil) + } label: { + Image(systemName: "minus") + .opacity(selection.wrappedValue.isEmpty ? 0.5 : 1) + } + .disabled(selection.wrappedValue.isEmpty) + } + .onDeleteCommand { + removePatterns(nil) + } + } +} diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/IgnorePatternListItemView.swift b/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift similarity index 70% rename from CodeEdit/Features/Settings/Pages/SearchSettings/IgnorePatternListItemView.swift rename to CodeEdit/Features/Settings/Views/GlobPatternListItem.swift index 0ba3f4ffd..539e08c97 100644 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/IgnorePatternListItemView.swift +++ b/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift @@ -1,5 +1,5 @@ // -// IgnorePatternListItemView.swift +// GlobPatternListItem.swift // CodeEdit // // Created by Esteban on 2/2/24. @@ -7,11 +7,11 @@ import SwiftUI -struct IgnorePatternListItem: View { +struct GlobPatternListItem: View { @Binding var pattern: GlobPattern - @Binding var selectedPattern: GlobPattern? + @Binding var selection: Set var addPattern: () -> Void - var removePattern: (GlobPattern) -> Void + var removePatterns: (_ selection: Set?) -> Void var focusedField: FocusState.Binding var isLast: Bool @@ -21,18 +21,19 @@ struct IgnorePatternListItem: View { init( pattern: Binding, - selectedPattern: Binding, + selection: Binding>, addPattern: @escaping () -> Void, - removePattern: @escaping (GlobPattern) -> Void, + removePatterns: @escaping (_ selection: Set?) -> Void, focusedField: FocusState.Binding, isLast: Bool ) { self._pattern = pattern - self._selectedPattern = selectedPattern + self._selection = selection self.addPattern = addPattern - self.removePattern = removePattern + self.removePatterns = removePatterns self.focusedField = focusedField self.isLast = isLast + self._value = State(initialValue: pattern.wrappedValue.value) } @@ -50,12 +51,13 @@ struct IgnorePatternListItem: View { } .onChange(of: isFocused) { newIsFocused in if newIsFocused { - if selectedPattern != pattern { - selectedPattern = pattern + if !selection.contains(pattern) { + selection.removeAll() + selection.insert(pattern) } } else { if value.isEmpty { - removePattern(pattern) + removePatterns(nil) } else { pattern.value = value } From 52b03efd16139e133c724aa98c3c9360b6cfc395 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 2 Nov 2024 10:30:56 -0500 Subject: [PATCH 12/27] Fixed a small race condition --- .../SourceControlSettings/Models/IgnorePatternModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 36078a40a..9a1eaab44 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -38,6 +38,7 @@ class IgnorePatternModel: ObservableObject { try? content.write(to: fileURL, atomically: true, encoding: .utf8) } + @MainActor func addPattern() { if patterns.isEmpty { Task { @@ -45,9 +46,12 @@ class IgnorePatternModel: ObservableObject { } } patterns.append(GlobPattern(value: "")) - savePatterns() + Task { + savePatterns() + } } + @MainActor func removePatterns(_ selection: Set? = nil) { let patternsToRemove = selection ?? self.selection patterns.removeAll { patternsToRemove.contains($0) } From 90934f21badaf94c461c3a7421a00b677ea3a865 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 2 Nov 2024 15:33:59 -0500 Subject: [PATCH 13/27] Added type to hasAppeared variable in SourceControlGitView. --- .../Pages/SourceControlSettings/SourceControlGitView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index c1dab364c..95e0f36f8 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -16,7 +16,7 @@ struct SourceControlGitView: View { @State private var authorName: String = "" @State private var authorEmail: String = "" @State private var preferRebaseWhenPulling: Bool = false - @State private var hasAppeared = false + @State private var hasAppeared: Bool = false var body: some View { SettingsForm { From 9635fe5756f644ef686a5acf3d7d9f90dbb63a4f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 16 Nov 2024 01:26:22 -0600 Subject: [PATCH 14/27] Renamed `enableSourceControl` to `sourceControlIsEnabled`. Added documentation and TODO. --- CodeEdit.xcodeproj/project.pbxproj | 12 ++++- ...WorkspaceFileManager+DirectoryEvents.swift | 2 +- .../Views/ToolbarBranchPicker.swift | 2 +- .../Views/InspectorAreaView.swift | 8 +-- .../Views/NavigatorAreaView.swift | 8 +-- .../Models/SourceControlSettings.swift | 12 ++--- .../SourceControlGeneralView.swift | 18 +++---- .../Client/GitConfigClient.swift | 51 ++++++++----------- .../Client/GitConfigExtensions.swift | 40 +++++++++++++++ .../Client/GitConfigRepresentable.swift | 19 +++++++ .../WindowCommands/CodeEditCommands.swift | 6 +-- CodeEdit/Utils/Limiter.swift | 1 + CodeEdit/WorkspaceView.swift | 6 +-- 13 files changed, 123 insertions(+), 62 deletions(-) create mode 100644 CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift create mode 100644 CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index e8c858c65..40107519f 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -578,6 +578,8 @@ B6AB09A32AAABFEC0003A3A6 /* EditorTabBarLeadingAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AB09A22AAABFEC0003A3A6 /* EditorTabBarLeadingAccessories.swift */; }; B6AB09A52AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AB09A42AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift */; }; B6AB09B32AB919CF0003A3A6 /* View+actionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AB09B22AB919CF0003A3A6 /* View+actionBar.swift */; }; + B6B2D79F2CE8794E00379967 /* GitConfigRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B2D79E2CE8794200379967 /* GitConfigRepresentable.swift */; }; + B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B2D7A02CE8797400379967 /* GitConfigExtensions.swift */; }; B6BF41422C2C672A003AB4B3 /* SourceControlPushView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BF41412C2C672A003AB4B3 /* SourceControlPushView.swift */; }; B6C4F2A12B3CA37500B2B140 /* SourceControlNavigatorHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2A02B3CA37500B2B140 /* SourceControlNavigatorHistoryView.swift */; }; B6C4F2A32B3CA74800B2B140 /* CommitDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C4F2A22B3CA74800B2B140 /* CommitDetailsView.swift */; }; @@ -590,8 +592,8 @@ B6CFD8112C20A8EE00E63F1A /* NSFont+WithWeight.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */; }; B6D7EA592971078500301FAC /* InspectorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA582971078500301FAC /* InspectorSection.swift */; }; B6D7EA5C297107DD00301FAC /* InspectorField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA5B297107DD00301FAC /* InspectorField.swift */; }; - B6E38E022CD3E63A00F4E65A /* GitConfigClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */; }; B6DCDAC62CCDE2B90099FBF9 /* InstantPopoverModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */; }; + B6E38E022CD3E63A00F4E65A /* GitConfigClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */; }; B6E41C7029DD157F0088F9F4 /* AccountsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */; }; B6E41C7429DD40010088F9F4 /* View+HideSidebarToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */; }; B6E41C7929DE02800088F9F4 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */; }; @@ -1248,6 +1250,8 @@ B6AB09A22AAABFEC0003A3A6 /* EditorTabBarLeadingAccessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarLeadingAccessories.swift; sourceTree = ""; }; B6AB09A42AAAC00F0003A3A6 /* EditorTabBarTrailingAccessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarTrailingAccessories.swift; sourceTree = ""; }; B6AB09B22AB919CF0003A3A6 /* View+actionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+actionBar.swift"; sourceTree = ""; }; + B6B2D79E2CE8794200379967 /* GitConfigRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitConfigRepresentable.swift; sourceTree = ""; }; + B6B2D7A02CE8797400379967 /* GitConfigExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitConfigExtensions.swift; sourceTree = ""; }; B6BF41412C2C672A003AB4B3 /* SourceControlPushView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlPushView.swift; sourceTree = ""; }; B6C4F2A02B3CA37500B2B140 /* SourceControlNavigatorHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceControlNavigatorHistoryView.swift; sourceTree = ""; }; B6C4F2A22B3CA74800B2B140 /* CommitDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailsView.swift; sourceTree = ""; }; @@ -1260,8 +1264,8 @@ B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSFont+WithWeight.swift"; sourceTree = ""; }; B6D7EA582971078500301FAC /* InspectorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorSection.swift; sourceTree = ""; }; B6D7EA5B297107DD00301FAC /* InspectorField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorField.swift; sourceTree = ""; }; - B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitConfigClient.swift; sourceTree = ""; }; B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantPopoverModifier.swift; sourceTree = ""; }; + B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitConfigClient.swift; sourceTree = ""; }; B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsView.swift; sourceTree = ""; }; B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+HideSidebarToggle.swift"; sourceTree = ""; }; B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = ""; }; @@ -2269,6 +2273,8 @@ children = ( 58A5DF7F29325B5A00D1BD5D /* GitClient.swift */, B6E38E012CD3E62E00F4E65A /* GitConfigClient.swift */, + B6B2D79E2CE8794200379967 /* GitConfigRepresentable.swift */, + B6B2D7A02CE8797400379967 /* GitConfigExtensions.swift */, 04BA7C182AE2D7C600584E1C /* GitClient+Branches.swift */, 04BA7C1D2AE2D8A000584E1C /* GitClient+Clone.swift */, 04BA7C1B2AE2D84100584E1C /* GitClient+Commit.swift */, @@ -4063,6 +4069,7 @@ 77A01E2A2BB424EA00F0EA38 /* CEWorkspaceSettingsData+ProjectSettings.swift in Sources */, 852C7E332A587279006BA599 /* SearchableSettingsPage.swift in Sources */, 587B9E5F29301D8F00AC7927 /* GitLabProjectRouter.swift in Sources */, + B6B2D7A12CE8797B00379967 /* GitConfigExtensions.swift in Sources */, 587B9E7329301D8F00AC7927 /* GitRouter.swift in Sources */, 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */, @@ -4331,6 +4338,7 @@ B6E41C7929DE02800088F9F4 /* AccountSelectionView.swift in Sources */, 6C48B5CE2C0C1BE4001E9955 /* Shell.swift in Sources */, 6CA1AE952B46950000378EAB /* EditorInstance.swift in Sources */, + B6B2D79F2CE8794E00379967 /* GitConfigRepresentable.swift in Sources */, 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */, B6C4F2A92B3CB00100B2B140 /* CommitDetailsHeaderView.swift in Sources */, 30B088012C0D53080063A882 /* LanguageServer+Definition.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index 618262008..3a81f3488 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -48,7 +48,7 @@ extension CEWorkspaceFileManager { self.notifyObservers(updatedItems: files) } - if Settings.shared.preferences.sourceControl.general.enableSourceControl && + if Settings.shared.preferences.sourceControl.general.sourceControlIsEnabled && Settings.shared.preferences.sourceControl.general.refreshStatusLocally { self.handleGitEvents(events: events) } diff --git a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift index 193e4d98b..b199e7b20 100644 --- a/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift +++ b/CodeEdit/Features/CodeEditUI/Views/ToolbarBranchPicker.swift @@ -84,7 +84,7 @@ struct ToolbarBranchPicker: View { self.currentBranch = branch } .task { - if Settings.shared.preferences.sourceControl.general.enableSourceControl { + if Settings.shared.preferences.sourceControl.general.sourceControlIsEnabled { await self.sourceControlManager?.refreshCurrentBranch() await self.sourceControlManager?.refreshBranches() } diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift index 4c47109bc..aaecbd0a4 100644 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift @@ -8,8 +8,8 @@ struct InspectorAreaView: View { @EnvironmentObject private var editorManager: EditorManager - @AppSettings(\.sourceControl.general.enableSourceControl) - private var enableSourceControl: Bool + @AppSettings(\.sourceControl.general.sourceControlIsEnabled) + private var sourceControlIsEnabled: Bool @AppSettings(\.general.inspectorTabBarPosition) var sidebarPosition: SettingsData.SidebarTabBarPosition @@ -57,14 +57,14 @@ struct InspectorAreaView: View { .formStyle(.grouped) .accessibilityElement(children: .contain) .accessibilityLabel("inspector") - .onChange(of: enableSourceControl) { _ in + .onChange(of: sourceControlIsEnabled) { _ in updateTabItems() } } private func updateTabItems() { viewModel.tabItems = [.file] + - (enableSourceControl ? [.gitHistory] : []) + + (sourceControlIsEnabled ? [.gitHistory] : []) + extensionManager .extensions .flatMap { ext in diff --git a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift index 93bb7606f..5f2e605c8 100644 --- a/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift +++ b/CodeEdit/Features/NavigatorArea/Views/NavigatorAreaView.swift @@ -15,8 +15,8 @@ struct NavigatorAreaView: View { @AppSettings(\.general.navigatorTabBarPosition) var sidebarPosition: SettingsData.SidebarTabBarPosition - @AppSettings(\.sourceControl.general.enableSourceControl) - private var enableSourceControl: Bool + @AppSettings(\.sourceControl.general.sourceControlIsEnabled) + private var sourceControlIsEnabled: Bool init(workspace: WorkspaceDocument, viewModel: NavigatorSidebarViewModel) { self.workspace = workspace @@ -54,14 +54,14 @@ struct NavigatorAreaView: View { .environmentObject(workspace) .accessibilityElement(children: .contain) .accessibilityLabel("navigator") - .onChange(of: enableSourceControl) { _ in + .onChange(of: sourceControlIsEnabled) { _ in updateTabItems() } } private func updateTabItems() { viewModel.tabItems = [.project] + - (enableSourceControl ? [.sourceControl] : []) + + (sourceControlIsEnabled ? [.sourceControl] : []) + [.search] + extensionManager .extensions diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index 61128f9af..0629967f0 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -52,14 +52,14 @@ extension SettingsData { struct SourceControlGeneral: Codable, Hashable { /// Indicates whether or not the source control is active - var enableSourceControl: Bool = true - /// Indicates whether or not we should include the upstream changes + var sourceControlIsEnabled: Bool = true + /// Indicates whether the status should be refreshed locally without fetching updates from the server. var refreshStatusLocally: Bool = true - /// Indicates whether or not we should include the upstream changes + /// Indicates whether the application should automatically fetch updates from the server and refresh the status. var fetchRefreshServerStatus: Bool = true - /// Indicates whether or not we should include the upstream changes + /// Indicates whether new files should be automatically added and removed files should be removed from version control. var addRemoveAutomatically: Bool = true - /// Indicates whether or not we should include the upstream changes + /// Indicates whether the application should automatically select files to commit. var selectFilesToCommit: Bool = true /// Indicates whether or not to show the source control changes var showSourceControlChanges: Bool = true @@ -76,7 +76,7 @@ extension SettingsData { /// Explicit decoder init for setting default values when key is not present in `JSON` init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.enableSourceControl = try container.decodeIfPresent(Bool.self, forKey: .enableSourceControl) ?? true + self.sourceControlIsEnabled = try container.decodeIfPresent(Bool.self, forKey: .sourceControlIsEnabled) ?? true self.refreshStatusLocally = try container.decodeIfPresent(Bool.self, forKey: .refreshStatusLocally) ?? true self.fetchRefreshServerStatus = try container.decodeIfPresent( Bool.self, diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index 08535eb13..a964b73ab 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -19,7 +19,7 @@ struct SourceControlGeneralView: View { var body: some View { SettingsForm { Section("Source Control") { - enableSourceControl + sourceControlIsEnabled refreshLocalStatusAuto fetchRefreshStatusAuto addRemoveFilesAuto @@ -47,10 +47,10 @@ struct SourceControlGeneralView: View { } private extension SourceControlGeneralView { - private var enableSourceControl: some View { + private var sourceControlIsEnabled: some View { Toggle( "Enable source control", - isOn: $settings.enableSourceControl + isOn: $settings.sourceControlIsEnabled ) } @@ -59,7 +59,7 @@ private extension SourceControlGeneralView { "Refresh local status automatically", isOn: $settings.refreshStatusLocally ) - .disabled(!settings.enableSourceControl) + .disabled(!settings.sourceControlIsEnabled) } private var fetchRefreshStatusAuto: some View { @@ -67,7 +67,7 @@ private extension SourceControlGeneralView { "Fetch and refresh server status automatically", isOn: $settings.fetchRefreshServerStatus ) - .disabled(!settings.enableSourceControl) + .disabled(!settings.sourceControlIsEnabled) } private var addRemoveFilesAuto: some View { @@ -75,7 +75,7 @@ private extension SourceControlGeneralView { "Add and remove files automatically", isOn: $settings.addRemoveAutomatically ) - .disabled(!settings.enableSourceControl) + .disabled(!settings.sourceControlIsEnabled) } private var selectFilesToCommitAuto: some View { @@ -83,7 +83,7 @@ private extension SourceControlGeneralView { "Select files to commit automatically", isOn: $settings.selectFilesToCommit ) - .disabled(!settings.enableSourceControl) + .disabled(!settings.sourceControlIsEnabled) } private var showSourceControlChanges: some View { @@ -91,7 +91,7 @@ private extension SourceControlGeneralView { "Show source control changes", isOn: $settings.showSourceControlChanges ) - .disabled(!settings.enableSourceControl) + .disabled(!settings.sourceControlIsEnabled) } private var includeUpstreamChanges: some View { @@ -99,7 +99,7 @@ private extension SourceControlGeneralView { "Include upstream changes", isOn: $settings.includeUpstreamChanges ) - .disabled(!settings.enableSourceControl || !settings.showSourceControlChanges) + .disabled(!settings.sourceControlIsEnabled || !settings.showSourceControlChanges) } private var comparisonView: some View { diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift b/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift index a0497e97d..6928ba60a 100644 --- a/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift +++ b/CodeEdit/Features/SourceControl/Client/GitConfigClient.swift @@ -7,44 +7,27 @@ import Foundation -protocol GitConfigRepresentable { - init?(configValue: String) - var asConfigValue: String { get } -} - -extension Bool: GitConfigRepresentable { - init?(configValue: String) { - switch configValue.lowercased() { - case "true": self = true - case "false": self = false - default: return nil - } - } - - var asConfigValue: String { - self ? "true" : "false" - } -} - -extension String: GitConfigRepresentable { - init?(configValue: String) { - self = configValue - } - - var asConfigValue: String { - "\"\(self)\"" - } -} - +/// A client for managing Git configuration settings. +/// Provides methods to read and write Git configuration values at both +/// project and global levels. class GitConfigClient { private let projectURL: URL? private let shellClient: ShellClient + /// Initializes a new GitConfigClient. + /// - Parameters: + /// - projectURL: The project directory URL (if any). + /// - shellClient: The client responsible for executing shell commands. init(projectURL: URL? = nil, shellClient: ShellClient) { self.projectURL = projectURL self.shellClient = shellClient } + /// Runs a Git configuration command. + /// - Parameters: + /// - command: The Git command to execute. + /// - global: Whether to apply the command globally or locally. + /// - Returns: The command output as a string. private func runConfigCommand(_ command: String, global: Bool) async throws -> String { var fullCommand = "git config" @@ -58,12 +41,22 @@ class GitConfigClient { return try shellClient.run(fullCommand) } + /// Retrieves a Git configuration value. + /// - Parameters: + /// - key: The configuration key to retrieve. + /// - global: Whether to retrieve the value globally or locally. + /// - Returns: The value as a type conforming to `GitConfigRepresentable`, or `nil` if not found. func get(key: String, global: Bool = false) async throws -> T? { let output = try await runConfigCommand(key, global: global) let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) return T(configValue: trimmedOutput) } + /// Sets a Git configuration value. + /// - Parameters: + /// - key: The configuration key to set. + /// - value: The value to set, conforming to `GitConfigRepresentable`. + /// - global: Whether to set the value globally or locally. func set(key: String, value: T, global: Bool = false) async { let shouldUnset: Bool if let boolValue = value as? Bool { diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift b/CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift new file mode 100644 index 000000000..c3a98de0e --- /dev/null +++ b/CodeEdit/Features/SourceControl/Client/GitConfigExtensions.swift @@ -0,0 +1,40 @@ +// +// GitConfigExtensions.swift +// CodeEdit +// +// Created by Austin Condiff on 11/16/24. +// + +import Foundation + +/// Conformance of `Bool` to `GitConfigRepresentable` +/// +/// This enables `Bool` values to be represented in Git configuration as +/// `true` or `false`. +extension Bool: GitConfigRepresentable { + public init?(configValue: String) { + switch configValue.lowercased() { + case "true": self = true + case "false": self = false + default: return nil + } + } + + public var asConfigValue: String { + self ? "true" : "false" + } +} + +/// Conformance of `String` to `GitConfigRepresentable` +/// +/// This enables `String` values to be represented in Git configuration, +/// automatically escaping them with quotes. +extension String: GitConfigRepresentable { + public init?(configValue: String) { + self = configValue + } + + public var asConfigValue: String { + "\"\(self)\"" + } +} diff --git a/CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift b/CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift new file mode 100644 index 000000000..1eb55f502 --- /dev/null +++ b/CodeEdit/Features/SourceControl/Client/GitConfigRepresentable.swift @@ -0,0 +1,19 @@ +// +// GitConfigRepresentable.swift +// CodeEdit +// +// Created by Austin Condiff on 11/16/24. +// + +/// A protocol that provides a mechanism to represent and parse Git configuration values. +/// +/// Conforming types must be able to initialize from a Git configuration string +/// and convert their value back to a Git-compatible string representation. +protocol GitConfigRepresentable { + /// Initializes a new instance from a Git configuration value string. + /// - Parameter configValue: The configuration value string. + init?(configValue: String) + + /// Converts the value to a Git-compatible configuration string. + var asConfigValue: String { get } +} diff --git a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift index fd860b59f..ef5714559 100644 --- a/CodeEdit/Features/WindowCommands/CodeEditCommands.swift +++ b/CodeEdit/Features/WindowCommands/CodeEditCommands.swift @@ -8,8 +8,8 @@ import SwiftUI struct CodeEditCommands: Commands { - @AppSettings(\.sourceControl.general.enableSourceControl) - private var enableSourceControl + @AppSettings(\.sourceControl.general.sourceControlIsEnabled) + private var sourceControlIsEnabled var body: some Commands { MainCommands() @@ -17,7 +17,7 @@ struct CodeEditCommands: Commands { ViewCommands() FindCommands() NavigateCommands() - if enableSourceControl { SourceControlCommands() } + if sourceControlIsEnabled { SourceControlCommands() } ExtensionCommands() WindowCommands() HelpCommands() diff --git a/CodeEdit/Utils/Limiter.swift b/CodeEdit/Utils/Limiter.swift index 52613834f..154a82757 100644 --- a/CodeEdit/Utils/Limiter.swift +++ b/CodeEdit/Utils/Limiter.swift @@ -8,6 +8,7 @@ import Combine import Foundation +// TODO: Look into improving this API by using async by default so `Task` isn't needed when used. enum Limiter { // Keep track of debounce timers and throttle states private static var debounceTimers: [AnyHashable: AnyCancellable] = [:] diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index c2a15162c..6782a8cf6 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -19,8 +19,8 @@ struct WorkspaceView: View { @AppSettings(\.theme.matchAppearance) var matchAppearance - @AppSettings(\.sourceControl.general.enableSourceControl) - var enableSourceControl + @AppSettings(\.sourceControl.general.sourceControlIsEnabled) + var sourceControlIsEnabled @EnvironmentObject private var workspace: WorkspaceDocument @EnvironmentObject private var editorManager: EditorManager @@ -133,7 +133,7 @@ struct WorkspaceView: View { : themeModel.selectedLightTheme } } - .onChange(of: enableSourceControl) { newValue in + .onChange(of: sourceControlIsEnabled) { newValue in if !newValue { Task { await sourceControlManager.refreshCurrentBranch() From 71ee6c66723c807c97146beb3eb8e58b9e9b2d66 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 25 Nov 2024 16:47:30 -0600 Subject: [PATCH 15/27] Ignored files can be changed while preserving comments and white space in the users .gitignore_global file. Users can now reorder ignored files. Buttons were added in settings to open .gitconfig and .gitignore_global in an editor. --- .../Models/SearchSettingsModel.swift | 10 +- .../IgnoredFilesListView.swift | 2 +- .../Models/IgnorePatternModel.swift | 192 +++++++++++++++++- .../SourceControlGeneralView.swift | 2 +- .../SourceControlGitView.swift | 48 ++++- .../Settings/Views/GlobPatternList.swift | 33 ++- .../Settings/Views/GlobPatternListItem.swift | 31 +-- 7 files changed, 267 insertions(+), 51 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift b/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift index f15d0b941..41a798a5d 100644 --- a/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift +++ b/CodeEdit/Features/Settings/Pages/SearchSettings/Models/SearchSettingsModel.swift @@ -48,7 +48,7 @@ final class SearchSettingsModel: ObservableObject { } /// Selected patterns - @Published var selection: Set = [] + @Published var selection: Set = [] /// Stores the new values from the Search Settings Model into the settings.json whenever /// `ignoreGlobPatterns` is updated @@ -60,12 +60,16 @@ final class SearchSettingsModel: ObservableObject { } } + func getPattern(for id: UUID) -> GlobPattern? { + return ignoreGlobPatterns.first(where: { $0.id == id }) + } + func addPattern() { ignoreGlobPatterns.append(GlobPattern(value: "")) } - func removePatterns(_ selection: Set? = nil) { - let patternsToRemove = selection ?? self.selection + func removePatterns(_ selection: Set? = nil) { + let patternsToRemove = selection?.compactMap { getPattern(for: $0) } ?? [] ignoreGlobPatterns.removeAll { patternsToRemove.contains($0) } self.selection.removeAll() } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift index 42e50942a..fd8f230dd 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/IgnoredFilesListView.swift @@ -8,7 +8,7 @@ import SwiftUI struct IgnoredFilesListView: View { - @ObservedObject private var model = IgnorePatternModel() + @StateObject private var model = IgnorePatternModel() var body: some View { GlobPatternList( diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 9a1eaab44..b68d19e9b 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -8,18 +8,64 @@ import Foundation class IgnorePatternModel: ObservableObject { - @Published var patterns: [GlobPattern] = [] - @Published var selection: Set = [] - - let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + @Published var loadingPatterns: Bool = false + @Published var patterns: [GlobPattern] = [] { + didSet { + if !loadingPatterns { + savePatterns() + } else { + loadingPatterns = false + } + } + } + @Published var selection: Set = [] - let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") + private let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + private let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") + private var fileMonitor: DispatchSourceFileSystemObject? init() { loadPatterns() + startFileMonitor() + } + + deinit { + stopFileMonitor() + } + + private func startFileMonitor() { + let fileDescriptor = open(fileURL.path, O_EVTONLY) + guard fileDescriptor != -1 else { + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: .write, + queue: DispatchQueue.main + ) + + source.setEventHandler { [weak self] in + self?.loadPatterns() + } + + source.setCancelHandler { + close(fileDescriptor) + } + + fileMonitor?.cancel() // Cancel any existing monitor + fileMonitor = source + source.resume() + } + + private func stopFileMonitor() { + fileMonitor?.cancel() + fileMonitor = nil } func loadPatterns() { + loadingPatterns = true + guard FileManager.default.fileExists(atPath: fileURL.path) else { patterns = [] return @@ -33,11 +79,139 @@ class IgnorePatternModel: ObservableObject { } } + // Map to track the line numbers of patterns. + var patternLineMapping: [String: Int] = [:] + + func getPattern(for id: UUID) -> GlobPattern? { + return patterns.first(where: { $0.id == id }) + } + func savePatterns() { + // Suspend the file monitor to avoid self-triggered updates + stopFileMonitor() + + defer { + startFileMonitor() + } + + // Get the file contents; if the file doesn't exist, create it with the patterns + guard let fileContent = try? String(contentsOf: fileURL) else { + writeAllPatterns() + return + } + + let lines = fileContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var patternToLineIndex: [String: Int] = [:] // Map patterns to their line indices + var reorderedLines: [String] = [] // Store the final reordered lines + + // Map existing patterns in the file + for (index, line) in lines.enumerated() { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if !trimmedLine.isEmpty && !trimmedLine.hasPrefix("#") { + patternToLineIndex[trimmedLine] = index + } + } + + // Add patterns in the new order specified by the `patterns` array + for pattern in patterns { + let value = pattern.value + if let index = patternToLineIndex[value] { + // Keep the original line if it matches a pattern + reorderedLines.append(lines[index]) + patternToLineIndex.removeValue(forKey: value) + } else { + // Add new patterns that don't exist in the file + reorderedLines.append(value) + } + } + + // Add remaining non-pattern lines (comments, whitespace) + for (index, line) in lines.enumerated() { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { + reorderedLines.insert(line, at: index) + } + } + + // Ensure single blank line at the end + reorderedLines = cleanUpWhitespace(in: reorderedLines) + + // Write the updated content back to the file + let updatedContent = reorderedLines.joined(separator: "\n") + try? updatedContent.write(to: fileURL, atomically: true, encoding: .utf8) + } + + private func writeAllPatterns() { let content = patterns.map(\.value).joined(separator: "\n") try? content.write(to: fileURL, atomically: true, encoding: .utf8) } + private func handlePatterns( + _ lines: inout [String], + existingPatterns: inout Set, + patternLineMap: inout [String: Int] + ) { + var handledPatterns = Set() + + // Update or preserve existing patterns + for pattern in patterns { + let value = pattern.value + if let lineIndex = patternLineMap[value] { + // Pattern already exists, update it in place + lines[lineIndex] = value + handledPatterns.insert(value) + } else { + // Check if the pattern has been edited and corresponds to a previous pattern + if let oldPattern = existingPatterns.first(where: { !handledPatterns.contains($0) && $0 != value }), + let lineIndex = patternLineMap[oldPattern] { + lines[lineIndex] = value + existingPatterns.remove(oldPattern) + patternLineMap[value] = lineIndex + handledPatterns.insert(value) + } else { + // Append new patterns at the end + if let lastLine = lines.last, lastLine.trimmingCharacters(in: .whitespaces).isEmpty { + lines.removeLast() // Remove trailing blank line before appending + } + lines.append(value) + } + } + } + + // Remove patterns no longer in the list + let currentPatterns = Set(patterns.map(\.value)) + lines = lines.filter { line in + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + return trimmedLine.isEmpty || trimmedLine.hasPrefix("#") || currentPatterns.contains(trimmedLine) + } + } + + private func cleanUpWhitespace(in lines: [String]) -> [String] { + var cleanedLines: [String] = [] + var previousLineWasBlank = false + + for line in lines { + let isBlank = line.trimmingCharacters(in: .whitespaces).isEmpty + if !(isBlank && previousLineWasBlank) { + cleanedLines.append(line) + } + previousLineWasBlank = isBlank + } + + // Trim extra blank lines at the end, ensuring only a single blank line + while let lastLine = cleanedLines.last, lastLine.trimmingCharacters(in: .whitespaces).isEmpty { + cleanedLines.removeLast() + } + cleanedLines.append("") // Ensure exactly one blank line at the end + + // Trim whitespace at the top of the file + while let firstLine = cleanedLines.first, firstLine.trimmingCharacters(in: .whitespaces).isEmpty { + cleanedLines.removeFirst() + } + + return cleanedLines + } + @MainActor func addPattern() { if patterns.isEmpty { @@ -46,16 +220,12 @@ class IgnorePatternModel: ObservableObject { } } patterns.append(GlobPattern(value: "")) - Task { - savePatterns() - } } @MainActor - func removePatterns(_ selection: Set? = nil) { - let patternsToRemove = selection ?? self.selection + func removePatterns(_ selection: Set? = nil) { + let patternsToRemove = selection?.compactMap { getPattern(for: $0) } ?? [] patterns.removeAll { patternsToRemove.contains($0) } - savePatterns() self.selection.removeAll() } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index a964b73ab..5842b520e 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -38,7 +38,7 @@ struct SourceControlGeneralView: View { .onAppear { Task { defaultBranch = try await gitConfig.get(key: "init.defaultBranch", global: true) ?? "" - Task { + DispatchQueue.main.async { hasAppeared = true } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 95e0f36f8..9a5410891 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -27,11 +27,15 @@ struct SourceControlGitView: View { Section { preferToRebaseWhenPulling showMergeCommitsInPerFileLog + } footer: { + Button("Open in Editor...", action: openGitConfigFile) } Section { IgnoredFilesListView() } header: { Text("Ignored Files") + } footer: { + Button("Open in Editor...", action: openGitIgnoreFile) } } .onAppear { @@ -39,9 +43,8 @@ struct SourceControlGitView: View { authorName = try await gitConfig.get(key: "user.name", global: true) ?? "" authorEmail = try await gitConfig.get(key: "user.email", global: true) ?? "" preferRebaseWhenPulling = try await gitConfig.get(key: "pull.rebase", global: true) ?? false - Task { - hasAppeared = true - } + try? await Task.sleep(for: .milliseconds(0)) + hasAppeared = true } } } @@ -113,4 +116,43 @@ private extension SourceControlGitView { Spacer() } } + + private func openGitConfigFile() { + let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitconfig") + + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + + NSDocumentController.shared.openDocument( + withContentsOf: fileURL, + display: true + ) { _, _, error in + if let error = error { + print("Failed to open document: \(error.localizedDescription)") + } + } + } + + private func openGitIgnoreFile() { + let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") + + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + guard !FileManager.default.fileExists(atPath: fileURL.path) else { return } + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + Task { + await gitConfig.set(key: "core.excludesfile", value: fileURL.path, global: true) + } + } + + NSDocumentController.shared.openDocument( + withContentsOf: fileURL, + display: true + ) { _, _, error in + if let error = error { + print("Failed to open document: \(error.localizedDescription)") + } + } + } } diff --git a/CodeEdit/Features/Settings/Views/GlobPatternList.swift b/CodeEdit/Features/Settings/Views/GlobPatternList.swift index 704c21052..80f216a4b 100644 --- a/CodeEdit/Features/Settings/Views/GlobPatternList.swift +++ b/CodeEdit/Features/Settings/Views/GlobPatternList.swift @@ -9,19 +9,19 @@ import SwiftUI struct GlobPatternList: View { @Binding var patterns: [GlobPattern] - let selection: Binding> + @Binding var selection: Set let addPattern: () -> Void - let removePatterns: (_ selection: Set?) -> Void + let removePatterns: (_ selection: Set?) -> Void let emptyMessage: String @FocusState private var focusedField: String? var body: some View { - List(selection: selection) { - ForEach(Array(patterns.enumerated()), id: \.element) { index, pattern in + List(selection: $selection) { + ForEach(Array(patterns.enumerated()), id: \.element.id) { index, pattern in GlobPatternListItem( pattern: $patterns[index], - selection: selection, + selection: $selection, addPattern: addPattern, removePatterns: removePatterns, focusedField: $focusedField, @@ -36,13 +36,14 @@ struct GlobPatternList: View { .onMove { fromOffsets, toOffset in patterns.move(fromOffsets: fromOffsets, toOffset: toOffset) } - .onDelete { _ in - removePatterns(nil) + .onDelete { indexSet in + let patternIDs = indexSet.compactMap { patterns[$0].id } + removePatterns(Set(patternIDs)) } } .frame(minHeight: 96) - .contextMenu(forSelectionType: GlobPattern.self, menu: { selection in - if let pattern = selection.first { + .contextMenu(forSelectionType: UUID.self, menu: { selection in + if let patternID = selection.first, let pattern = patterns.first(where: { $0.id == patternID }) { Button("Edit") { focusedField = pattern.id.uuidString } @@ -51,13 +52,11 @@ struct GlobPatternList: View { } Divider() Button("Remove") { - if !patterns.isEmpty { - removePatterns(selection) - } + removePatterns(selection) } } }, primaryAction: { selection in - if let pattern = selection.first { + if let patternID = selection.first, let pattern = patterns.first(where: { $0.id == patternID }) { focusedField = pattern.id.uuidString } }) @@ -73,15 +72,15 @@ struct GlobPatternList: View { } Divider() Button { - removePatterns(nil) + removePatterns(selection) } label: { Image(systemName: "minus") - .opacity(selection.wrappedValue.isEmpty ? 0.5 : 1) + .opacity(selection.isEmpty ? 0.5 : 1) } - .disabled(selection.wrappedValue.isEmpty) + .disabled(selection.isEmpty) } .onDeleteCommand { - removePatterns(nil) + removePatterns(selection) } } } diff --git a/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift b/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift index 539e08c97..362f6b751 100644 --- a/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift +++ b/CodeEdit/Features/Settings/Views/GlobPatternListItem.swift @@ -9,9 +9,9 @@ import SwiftUI struct GlobPatternListItem: View { @Binding var pattern: GlobPattern - @Binding var selection: Set + @Binding var selection: Set var addPattern: () -> Void - var removePatterns: (_ selection: Set?) -> Void + var removePatterns: (_ selection: Set?) -> Void var focusedField: FocusState.Binding var isLast: Bool @@ -21,9 +21,9 @@ struct GlobPatternListItem: View { init( pattern: Binding, - selection: Binding>, + selection: Binding>, addPattern: @escaping () -> Void, - removePatterns: @escaping (_ selection: Set?) -> Void, + removePatterns: @escaping (_ selection: Set?) -> Void, focusedField: FocusState.Binding, isLast: Bool ) { @@ -45,22 +45,23 @@ struct GlobPatternListItem: View { .autocorrectionDisabled() .labelsHidden() .onSubmit { - if !value.isEmpty && isLast { - addPattern() + if !value.isEmpty { + if isLast { + addPattern() + } else { + selection.insert(pattern.id) + } } } .onChange(of: isFocused) { newIsFocused in if newIsFocused { - if !selection.contains(pattern) { - selection.removeAll() - selection.insert(pattern) - } - } else { - if value.isEmpty { - removePatterns(nil) - } else { - pattern.value = value + if !selection.contains(pattern.id) { + selection = [pattern.id] } + } else if pattern.value.isEmpty { + removePatterns(selection) + } else if pattern.value != value { + pattern.value = value } } } From 23148b3799080b264788392404cae93c2851ef7b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 25 Nov 2024 17:06:03 -0600 Subject: [PATCH 16/27] Fixed SwiftLint errors --- .../Models/SourceControlSettings.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift index 0629967f0..309bdf029 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/SourceControlSettings.swift @@ -57,7 +57,7 @@ extension SettingsData { var refreshStatusLocally: Bool = true /// Indicates whether the application should automatically fetch updates from the server and refresh the status. var fetchRefreshServerStatus: Bool = true - /// Indicates whether new files should be automatically added and removed files should be removed from version control. + /// Indicates whether new and deleted files should be automatically staged for commit. var addRemoveAutomatically: Bool = true /// Indicates whether the application should automatically select files to commit. var selectFilesToCommit: Bool = true @@ -76,7 +76,10 @@ extension SettingsData { /// Explicit decoder init for setting default values when key is not present in `JSON` init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.sourceControlIsEnabled = try container.decodeIfPresent(Bool.self, forKey: .sourceControlIsEnabled) ?? true + self.sourceControlIsEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .sourceControlIsEnabled + ) ?? true self.refreshStatusLocally = try container.decodeIfPresent(Bool.self, forKey: .refreshStatusLocally) ?? true self.fetchRefreshServerStatus = try container.decodeIfPresent( Bool.self, From eab7301fd96a6c4db724dd9f9776c36e10821c6f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 26 Nov 2024 01:05:00 -0600 Subject: [PATCH 17/27] Ignore pattern reorder fixes --- .../Models/IgnorePatternModel.swift | 95 +++++++++++++------ .../SourceControlGitView.swift | 49 +++++++--- .../Settings/Views/GlobPatternListItem.swift | 4 +- 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index b68d19e9b..035b10aa8 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -87,58 +87,97 @@ class IgnorePatternModel: ObservableObject { } func savePatterns() { - // Suspend the file monitor to avoid self-triggered updates - stopFileMonitor() - - defer { - startFileMonitor() - } + stopFileMonitor() // Suspend the file monitor to avoid self-triggered updates + defer { startFileMonitor() } - // Get the file contents; if the file doesn't exist, create it with the patterns guard let fileContent = try? String(contentsOf: fileURL) else { writeAllPatterns() return } let lines = fileContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - var patternToLineIndex: [String: Int] = [:] // Map patterns to their line indices - var reorderedLines: [String] = [] // Store the final reordered lines + let (patternToLineIndex, nonPatternLines) = mapLines(lines) + let globalCommentLines = extractGlobalComments(nonPatternLines, patternToLineIndex) + + var reorderedLines = reorderPatterns(globalCommentLines, patternToLineIndex, nonPatternLines, lines) + + // Ensure single blank line at the end + reorderedLines = cleanUpWhitespace(in: reorderedLines) + + // Write the updated content back to the file + let updatedContent = reorderedLines.joined(separator: "\n") + try? updatedContent.write(to: fileURL, atomically: true, encoding: .utf8) + } + + private func mapLines(_ lines: [String]) -> ([String: Int], [(line: String, index: Int)]) { + var patternToLineIndex: [String: Int] = [:] + var nonPatternLines: [(line: String, index: Int)] = [] - // Map existing patterns in the file for (index, line) in lines.enumerated() { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if !trimmedLine.isEmpty && !trimmedLine.hasPrefix("#") { patternToLineIndex[trimmedLine] = index + } else if index != lines.count - 1 { + nonPatternLines.append((line: line, index: index)) } } - // Add patterns in the new order specified by the `patterns` array + return (patternToLineIndex, nonPatternLines) + } + + private func extractGlobalComments( + _ nonPatternLines: [(line: String, index: Int)], + _ patternToLineIndex: [String: Int] + ) -> [String] { + let globalComments = nonPatternLines.filter { $0.index < (patternToLineIndex.values.min() ?? Int.max) } + return globalComments.map(\.line) + } + + private func reorderPatterns( + _ globalCommentLines: [String], + _ patternToLineIndex: [String: Int], + _ nonPatternLines: [(line: String, index: Int)], + _ lines: [String] + ) -> [String] { + var reorderedLines: [String] = globalCommentLines + var usedNonPatternLines = Set() + var usedPatterns = Set() + for pattern in patterns { let value = pattern.value - if let index = patternToLineIndex[value] { - // Keep the original line if it matches a pattern - reorderedLines.append(lines[index]) - patternToLineIndex.removeValue(forKey: value) - } else { - // Add new patterns that don't exist in the file - reorderedLines.append(value) + + // Insert the pattern + reorderedLines.append(value) + usedPatterns.insert(value) + + // Preserve associated non-pattern lines + if let currentIndex = patternToLineIndex[value] { + for nextIndex in (currentIndex + 1).. Date: Tue, 26 Nov 2024 02:42:44 -0600 Subject: [PATCH 18/27] Writing relative url to gitconfig upon adding first pattern --- .../SourceControlSettings/Models/IgnorePatternModel.swift | 8 +++++++- .../SourceControlSettings/SourceControlGitView.swift | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 035b10aa8..1611e6c0f 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -182,6 +182,12 @@ class IgnorePatternModel: ObservableObject { private func writeAllPatterns() { let content = patterns.map(\.value).joined(separator: "\n") + Task { + let excludesfile: String? = try await gitConfig.get(key: "core.excludesfile") + if excludesfile == "" { + await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") + } + } try? content.write(to: fileURL, atomically: true, encoding: .utf8) } @@ -271,6 +277,6 @@ class IgnorePatternModel: ObservableObject { func setupGlobalIgnoreFile() async { guard !FileManager.default.fileExists(atPath: fileURL.path) else { return } FileManager.default.createFile(atPath: fileURL.path, contents: nil) - await gitConfig.set(key: "core.excludesfile", value: fileURL.path, global: true) + await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 6733a8c15..d4a64e7c7 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -157,7 +157,7 @@ private extension SourceControlGitView { // Fallback to `.gitignore_global` in the home directory fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") // Set the default path in Git config if not set - await gitConfig.set(key: "core.excludesfile", value: fileURL.path, global: true) + await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") } // Ensure the file exists From 284df2efc14cc712ddceb095b454f5f07f3dfcc6 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 26 Nov 2024 02:44:37 -0600 Subject: [PATCH 19/27] Cleaned up unecessary function --- .../Models/IgnorePatternModel.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 1611e6c0f..40b3cce4e 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -259,11 +259,6 @@ class IgnorePatternModel: ObservableObject { @MainActor func addPattern() { - if patterns.isEmpty { - Task { - await setupGlobalIgnoreFile() - } - } patterns.append(GlobPattern(value: "")) } @@ -273,10 +268,4 @@ class IgnorePatternModel: ObservableObject { patterns.removeAll { patternsToRemove.contains($0) } self.selection.removeAll() } - - func setupGlobalIgnoreFile() async { - guard !FileManager.default.fileExists(atPath: fileURL.path) else { return } - FileManager.default.createFile(atPath: fileURL.path, contents: nil) - await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") - } } From dbf09295457f50db13f834a5ef3e1b83a46952e9 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 26 Nov 2024 03:01:34 -0600 Subject: [PATCH 20/27] Trying to fix test --- .../Pages/SourceControlSettings/SourceControlGitView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index d4a64e7c7..3d2940f54 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -157,7 +157,7 @@ private extension SourceControlGitView { // Fallback to `.gitignore_global` in the home directory fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") // Set the default path in Git config if not set - await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") + await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)", global: true) } // Ensure the file exists From 0565305e87aa724e23d57e38048389497be8bbe5 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 30 Nov 2024 10:46:18 -0600 Subject: [PATCH 21/27] Dynamically resolve gitignore file path. --- .../Models/IgnorePatternModel.swift | 178 ++++++++++-------- .../SourceControlGeneralView.swift | 28 --- .../SourceControlGitView.swift | 151 ++++++++++----- 3 files changed, 196 insertions(+), 161 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 40b3cce4e..eaa0c73d5 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -21,23 +21,42 @@ class IgnorePatternModel: ObservableObject { @Published var selection: Set = [] private let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) - private let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") private var fileMonitor: DispatchSourceFileSystemObject? init() { - loadPatterns() - startFileMonitor() + Task { + try? await startFileMonitor() + await loadPatterns() + } } deinit { stopFileMonitor() } - private func startFileMonitor() { - let fileDescriptor = open(fileURL.path, O_EVTONLY) - guard fileDescriptor != -1 else { - return + private func gitIgnoreURL() async throws -> URL { + let excludesfile = try await gitConfig.get(key: "core.excludesfile") ?? "" + if !excludesfile.isEmpty { + if excludesfile.starts(with: "~/") { + let relativePath = String(excludesfile.dropFirst(2)) // Remove "~/" + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(relativePath) + } else if excludesfile.starts(with: "/") { + return URL(fileURLWithPath: excludesfile) // Absolute path + } else { + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesfile) + } + } else { + let defaultPath = ".gitignore_global" + let fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(defaultPath) + await gitConfig.set(key: "core.excludesfile", value: "~/\(defaultPath)", global: true) + return fileURL } + } + + private func startFileMonitor() async throws { + let fileURL = try await gitIgnoreURL() + let fileDescriptor = open(fileURL.path, O_EVTONLY) + guard fileDescriptor != -1 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, @@ -46,14 +65,16 @@ class IgnorePatternModel: ObservableObject { ) source.setEventHandler { [weak self] in - self?.loadPatterns() + Task { + await self?.loadPatterns() + } } source.setCancelHandler { close(fileDescriptor) } - fileMonitor?.cancel() // Cancel any existing monitor + fileMonitor?.cancel() fileMonitor = source source.resume() } @@ -63,50 +84,76 @@ class IgnorePatternModel: ObservableObject { fileMonitor = nil } - func loadPatterns() { - loadingPatterns = true + func loadPatterns() async { + await MainActor.run { loadingPatterns = true } // Ensure `loadingPatterns` is updated on the main thread - guard FileManager.default.fileExists(atPath: fileURL.path) else { - patterns = [] - return - } + do { + let fileURL = try await gitIgnoreURL() + guard FileManager.default.fileExists(atPath: fileURL.path) else { + await MainActor.run { + patterns = [] + loadingPatterns = false // Update on the main thread + } + return + } - if let content = try? String(contentsOf: fileURL) { - patterns = content.split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && !$0.starts(with: "#") } - .map { GlobPattern(value: String($0)) } + if let content = try? String(contentsOf: fileURL) { + let parsedPatterns = content.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !$0.starts(with: "#") } + .map { GlobPattern(value: String($0)) } + + await MainActor.run { + patterns = parsedPatterns // Update `patterns` on the main thread + loadingPatterns = false // Ensure `loadingPatterns` is updated on the main thread + } + } else { + await MainActor.run { + patterns = [] + loadingPatterns = false + } + } + } catch { + print("Error loading patterns: \(error)") + await MainActor.run { + patterns = [] + loadingPatterns = false + } } } - // Map to track the line numbers of patterns. - var patternLineMapping: [String: Int] = [:] - func getPattern(for id: UUID) -> GlobPattern? { return patterns.first(where: { $0.id == id }) } func savePatterns() { - stopFileMonitor() // Suspend the file monitor to avoid self-triggered updates - defer { startFileMonitor() } - - guard let fileContent = try? String(contentsOf: fileURL) else { - writeAllPatterns() - return - } + Task { + stopFileMonitor() + defer { Task { try? await startFileMonitor() } } + + do { + let fileURL = try await gitIgnoreURL() + guard let fileContent = try? String(contentsOf: fileURL) else { + writeAllPatterns() + return + } - let lines = fileContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let (patternToLineIndex, nonPatternLines) = mapLines(lines) - let globalCommentLines = extractGlobalComments(nonPatternLines, patternToLineIndex) + let lines = fileContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let (patternToLineIndex, nonPatternLines) = mapLines(lines) + let globalCommentLines = extractGlobalComments(nonPatternLines, patternToLineIndex) - var reorderedLines = reorderPatterns(globalCommentLines, patternToLineIndex, nonPatternLines, lines) + var reorderedLines = reorderPatterns(globalCommentLines, patternToLineIndex, nonPatternLines, lines) - // Ensure single blank line at the end - reorderedLines = cleanUpWhitespace(in: reorderedLines) + // Ensure single blank line at the end + reorderedLines = cleanUpWhitespace(in: reorderedLines) - // Write the updated content back to the file - let updatedContent = reorderedLines.joined(separator: "\n") - try? updatedContent.write(to: fileURL, atomically: true, encoding: .utf8) + // Write the updated content back to the file + let updatedContent = reorderedLines.joined(separator: "\n") + try updatedContent.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + print("Error saving patterns: \(error)") + } + } } private func mapLines(_ lines: [String]) -> ([String: Int], [(line: String, index: Int)]) { @@ -181,53 +228,18 @@ class IgnorePatternModel: ObservableObject { } private func writeAllPatterns() { - let content = patterns.map(\.value).joined(separator: "\n") Task { - let excludesfile: String? = try await gitConfig.get(key: "core.excludesfile") - if excludesfile == "" { - await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)") - } - } - try? content.write(to: fileURL, atomically: true, encoding: .utf8) - } - - private func handlePatterns( - _ lines: inout [String], - existingPatterns: inout Set, - patternLineMap: inout [String: Int] - ) { - var handledPatterns = Set() - - // Update or preserve existing patterns - for pattern in patterns { - let value = pattern.value - if let lineIndex = patternLineMap[value] { - // Pattern already exists, update it in place - lines[lineIndex] = value - handledPatterns.insert(value) - } else { - // Check if the pattern has been edited and corresponds to a previous pattern - if let oldPattern = existingPatterns.first(where: { !handledPatterns.contains($0) && $0 != value }), - let lineIndex = patternLineMap[oldPattern] { - lines[lineIndex] = value - existingPatterns.remove(oldPattern) - patternLineMap[value] = lineIndex - handledPatterns.insert(value) - } else { - // Append new patterns at the end - if let lastLine = lines.last, lastLine.trimmingCharacters(in: .whitespaces).isEmpty { - lines.removeLast() // Remove trailing blank line before appending - } - lines.append(value) + do { + let fileURL = try await gitIgnoreURL() + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) } - } - } - // Remove patterns no longer in the list - let currentPatterns = Set(patterns.map(\.value)) - lines = lines.filter { line in - let trimmedLine = line.trimmingCharacters(in: .whitespaces) - return trimmedLine.isEmpty || trimmedLine.hasPrefix("#") || currentPatterns.contains(trimmedLine) + let content = patterns.map(\.value).joined(separator: "\n") + try content.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + print("Failed to write all patterns: \(error)") + } } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index 5842b520e..49159c474 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -13,9 +13,6 @@ struct SourceControlGeneralView: View { let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) - @State private var defaultBranch: String = "" - @State private var hasAppeared = false - var body: some View { SettingsForm { Section("Source Control") { @@ -32,15 +29,6 @@ struct SourceControlGeneralView: View { Section { comparisonView sourceControlNavigator - defaultBranchName - } - } - .onAppear { - Task { - defaultBranch = try await gitConfig.get(key: "init.defaultBranch", global: true) ?? "" - DispatchQueue.main.async { - hasAppeared = true - } } } } @@ -125,20 +113,4 @@ private extension SourceControlGeneralView { .tag(SettingsData.ControlNavigatorOrder.sortByDate) } } - - private var defaultBranchName: some View { - TextField(text: $defaultBranch) { - Text("Default branch name") - Text("Cannot contain spaces, backslashes, or other symbols") - } - .onChange(of: defaultBranch) { newValue in - if hasAppeared { - Limiter.debounce(id: "defaultBranchDebouncer", duration: 0.5) { - Task { - await gitConfig.set(key: "init.defaultBranch", value: newValue, global: true) - } - } - } - } - } } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index 3d2940f54..cff64571b 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -15,33 +15,50 @@ struct SourceControlGitView: View { @State private var authorName: String = "" @State private var authorEmail: String = "" + @State private var defaultBranch: String = "" @State private var preferRebaseWhenPulling: Bool = false @State private var hasAppeared: Bool = false + @State private var resolvedGitIgnorePath: String = "~/.gitignore_global" var body: some View { SettingsForm { Section { gitAuthorName gitEmail + } header: { + Text("Git Configuration") + Text(""" + Applied globally to all repositories on your Mac. \ + [Learn more...](https://git-scm.com/docs/git-config) + """) } Section { + defaultBranchName preferToRebaseWhenPulling showMergeCommitsInPerFileLog - } footer: { - Button("Open in Editor...", action: openGitConfigFile) + } + Section { + gitConfigEditor } Section { IgnoredFilesListView() } header: { Text("Ignored Files") - } footer: { - Button("Open in Editor...", action: openGitIgnoreFile) + Text(""" + Patterns for files and folders that Git should ignore and not track. \ + Applied globally to all repositories on your Mac. \ + [Learn more...](https://git-scm.com/docs/gitignore) + """) + } + Section { + gitIgnoreEditor } } .onAppear { Task { authorName = try await gitConfig.get(key: "user.name", global: true) ?? "" authorEmail = try await gitConfig.get(key: "user.email", global: true) ?? "" + defaultBranch = try await gitConfig.get(key: "init.defaultBranch", global: true) ?? "" preferRebaseWhenPulling = try await gitConfig.get(key: "pull.rebase", global: true) ?? false try? await Task.sleep(for: .milliseconds(0)) hasAppeared = true @@ -74,6 +91,22 @@ private extension SourceControlGitView { } } } + } + } + + private var defaultBranchName: some View { + TextField(text: $defaultBranch) { + Text("Default branch name") + Text("Cannot contain spaces, backslashes, or other symbols") + } + .onChange(of: defaultBranch) { newValue in + if hasAppeared { + Limiter.debounce(id: "defaultBranchDebouncer", duration: 0.5) { + Task { + await gitConfig.set(key: "init.defaultBranch", value: newValue, global: true) + } + } + } } } @@ -86,7 +119,6 @@ private extension SourceControlGitView { if hasAppeared { Limiter.debounce(id: "pullRebaseDebouncer", duration: 0.5) { Task { - print("Setting pull.rebase to \(newValue)") await gitConfig.set(key: "pull.rebase", value: newValue, global: true) } } @@ -101,19 +133,63 @@ private extension SourceControlGitView { ) } - private var bottomToolbar: some View { - HStack(spacing: 12) { - Button {} label: { - Image(systemName: "plus") - .foregroundColor(Color.secondary) + private var gitConfigEditor: some View { + HStack { + Text("Git configuration is stored in \"~/.gitconfig\".") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Open in Editor...", action: openGitConfigFile) + } + .frame(maxWidth: .infinity) + } + + private var gitIgnoreEditor: some View { + HStack { + Text("Ignored file patterns are stored in \"\(resolvedGitIgnorePath)\".") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Open in Editor...", action: openGitIgnoreFile) + } + .frame(maxWidth: .infinity) + .onAppear { + Task { + resolvedGitIgnorePath = await gitIgnorePath() } - .buttonStyle(.plain) - Button {} label: { - Image(systemName: "minus") + } + } + + private var gitIgnoreURL: URL { + get async throws { + if let excludesfile: String = try await gitConfig.get( + key: "core.excludesfile", + global: true + ), !excludesfile.isEmpty { + if excludesfile.starts(with: "~/") { + let relativePath = String(excludesfile.dropFirst(2)) + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(relativePath) + } else if excludesfile.starts(with: "/") { + return URL(fileURLWithPath: excludesfile) + } else { + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesfile) + } + } else { + let defaultURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + ".gitignore_global" + ) + await gitConfig.set(key: "core.excludesfile", value: "~/\(defaultURL.lastPathComponent)", global: true) + return defaultURL } - .disabled(true) - .buttonStyle(.plain) - Spacer() + } + } + + private func gitIgnorePath() async -> String { + do { + let url = try await gitIgnoreURL + return url.path.replacingOccurrences(of: FileManager.default.homeDirectoryForCurrentUser.path, with: "~") + } catch { + return "~/.gitignore_global" } } @@ -136,43 +212,18 @@ private extension SourceControlGitView { private func openGitIgnoreFile() { Task { - // Get the `core.excludesfile` configuration - let excludesfile: String? = try await gitConfig.get(key: "core.excludesfile") + do { + let fileURL = try await gitIgnoreURL - // Determine the file URL - let fileURL: URL - if let excludesfile, !excludesfile.isEmpty { - if excludesfile.starts(with: "~/") { - // If the path starts with "~/", expand it to the home directory - let relativePath = String(excludesfile.dropFirst(2)) // Remove "~/" - fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(relativePath) - } else if excludesfile.starts(with: "/") { - // If the path is absolute, use it directly - fileURL = URL(fileURLWithPath: excludesfile) - } else { - // Assume it's relative to the home directory - fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesfile) + // Ensure the file exists + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) } - } else { - // Fallback to `.gitignore_global` in the home directory - fileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gitignore_global") - // Set the default path in Git config if not set - await gitConfig.set(key: "core.excludesfile", value: "~/\(fileURL.lastPathComponent)", global: true) - } - - // Ensure the file exists - if !FileManager.default.fileExists(atPath: fileURL.path) { - FileManager.default.createFile(atPath: fileURL.path, contents: nil) - } - // Open the file in the editor - NSDocumentController.shared.openDocument( - withContentsOf: fileURL, - display: true - ) { _, _, error in - if let error = error { - print("Failed to open document: \(error.localizedDescription)") - } + // Open the file in the editor + try await NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) + } catch { + print("Failed to open document: \(error.localizedDescription)") } } } From 93b0be5fbe01344c4cc76a080e820bc45e53b96f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 30 Nov 2024 11:02:02 -0600 Subject: [PATCH 22/27] Fixing test --- .../SourceControlSettings/Models/IgnorePatternModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index eaa0c73d5..aab221e6c 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -64,9 +64,9 @@ class IgnorePatternModel: ObservableObject { queue: DispatchQueue.main ) - source.setEventHandler { [weak self] in + source.setEventHandler { Task { - await self?.loadPatterns() + await self.loadPatterns() } } From 464bdeaeba0f3258b3ca0aac6201fe21a929409e Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 2 Dec 2024 12:19:40 -0600 Subject: [PATCH 23/27] Added source control settings header. Moved feature icon into new view for reuse. --- CodeEdit.xcodeproj/project.pbxproj | 4 ++ .../SourceControlGeneralView.swift | 19 ++++++-- .../Features/Settings/Views/FeatureIcon.swift | 44 +++++++++++++++++++ .../Settings/Views/SettingsPageView.swift | 42 ++++++------------ .../View+NavigationBarBackButtonVisible.swift | 1 + 5 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 CodeEdit/Features/Settings/Views/FeatureIcon.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 87ef139c8..de24e6f7a 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -571,6 +571,7 @@ B6966A302C33282200259C2D /* RemoteBranchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A2F2C33282200259C2D /* RemoteBranchPicker.swift */; }; B6966A322C3348D300259C2D /* WorkspaceSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A312C3348D300259C2D /* WorkspaceSheets.swift */; }; B6966A342C34996B00259C2D /* SourceControlManager+GitClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */; }; + B696A7E62CFE20C40048CFE1 /* FeatureIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */; }; B697937A29FF5668002027EC /* AccountsSettingsAccountLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */; }; B69BFDC72B0686910050D9A6 /* GitClient+Initiate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */; }; B69D3EDE2C5E85A2005CF43A /* StopTaskToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */; }; @@ -1246,6 +1247,7 @@ B6966A2F2C33282200259C2D /* RemoteBranchPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteBranchPicker.swift; sourceTree = ""; }; B6966A312C3348D300259C2D /* WorkspaceSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSheets.swift; sourceTree = ""; }; B6966A332C34996B00259C2D /* SourceControlManager+GitClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceControlManager+GitClient.swift"; sourceTree = ""; }; + B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIcon.swift; sourceTree = ""; }; B697937929FF5668002027EC /* AccountsSettingsAccountLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsAccountLink.swift; sourceTree = ""; }; B69BFDC62B0686910050D9A6 /* GitClient+Initiate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GitClient+Initiate.swift"; sourceTree = ""; }; B69D3EDD2C5E85A2005CF43A /* StopTaskToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopTaskToolbarButton.swift; sourceTree = ""; }; @@ -3558,6 +3560,7 @@ B6CF632A29E5436C0085880A /* Views */ = { isa = PBXGroup; children = ( + B696A7E52CFE20C40048CFE1 /* FeatureIcon.swift */, B67DBB932CD5FBE2007F4F18 /* GlobPatternListItem.swift */, B67DBB912CD5EAA4007F4F18 /* GlobPatternList.swift */, B6041F4C29D7A4E9000F3454 /* SettingsPageView.swift */, @@ -4032,6 +4035,7 @@ 30B0880D2C0D53080063A882 /* LanguageServer+References.swift in Sources */, 77A01E2E2BB4261200F0EA38 /* CEWorkspaceSettings.swift in Sources */, 6C4104E9297C970F00F472BA /* AboutDefaultView.swift in Sources */, + B696A7E62CFE20C40048CFE1 /* FeatureIcon.swift in Sources */, 587B9E6F29301D8F00AC7927 /* GitLabProjectAccess.swift in Sources */, 587B9E6929301D8F00AC7927 /* GitLabEvent.swift in Sources */, B63F6A7B2C561BCB003B4342 /* RegexFormatter.swift in Sources */, diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index 49159c474..f25ced7ae 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -15,8 +15,10 @@ struct SourceControlGeneralView: View { var body: some View { SettingsForm { - Section("Source Control") { + Section { sourceControlIsEnabled + } + Section("Source Control") { refreshLocalStatusAuto fetchRefreshStatusAuto addRemoveFilesAuto @@ -37,9 +39,20 @@ struct SourceControlGeneralView: View { private extension SourceControlGeneralView { private var sourceControlIsEnabled: some View { Toggle( - "Enable source control", isOn: $settings.sourceControlIsEnabled - ) + ) { + Label { + Text("Source Control") + Text(""" + Back up your files, collaborate with others, and tag your releases. \ + [Learn more...](https://developer.apple.com/documentation/xcode/source-control-management) + """) + .font(.callout) + } icon: { + FeatureIcon(symbol: Image(symbol: "vault"), color: Color(.systemBlue), size: 26) + } + } + .controlSize(.large) } private var refreshLocalStatusAuto: some View { diff --git a/CodeEdit/Features/Settings/Views/FeatureIcon.swift b/CodeEdit/Features/Settings/Views/FeatureIcon.swift new file mode 100644 index 000000000..b7ca98e4e --- /dev/null +++ b/CodeEdit/Features/Settings/Views/FeatureIcon.swift @@ -0,0 +1,44 @@ +// +// FeatureIcon.swift +// CodeEdit +// +// Created by Austin Condiff on 12/2/24. +// + +import SwiftUI + +struct FeatureIcon: View { + private let symbol: Image + private let color: Color + private let size: CGFloat + + init( + symbol: Image?, + color: Color?, + size: CGFloat? + ) { + self.symbol = symbol ?? Image(systemName: "exclamationmark.triangle") + self.color = color ?? .white + self.size = size ?? 20 + } + + var body: some View { + Group { + symbol + .resizable() + .aspectRatio(contentMode: .fit) + } + .shadow(color: Color(NSColor.black).opacity(0.25), radius: size / 40, y: size / 40) + .padding(size / 8) + .foregroundColor(.white) + .frame(width: size, height: size) + .background( + RoundedRectangle( + cornerRadius: size / 4, + style: .continuous + ) + .fill(color.gradient) + .shadow(color: Color(NSColor.black).opacity(0.25), radius: size / 40, y: size / 40) + ) + } +} diff --git a/CodeEdit/Features/Settings/Views/SettingsPageView.swift b/CodeEdit/Features/Settings/Views/SettingsPageView.swift index a8f2fa011..e1c96460b 100644 --- a/CodeEdit/Features/Settings/Views/SettingsPageView.swift +++ b/CodeEdit/Features/Settings/Views/SettingsPageView.swift @@ -16,41 +16,25 @@ struct SettingsPageView: View { self.searchText = searchText } + var symbol: Image? { + switch page.icon { + case .system(let name): + Image(systemName: name) + case .symbol(let name): + Image(symbol: name) + case .asset(let name): + Image(name) + case .none: nil + } + } + var body: some View { NavigationLink(value: page) { Label { page.name.rawValue.highlightOccurrences(self.searchText) .padding(.leading, 2) } icon: { - Group { - switch page.icon { - case .system(let name): - Image(systemName: name) - .resizable() - .aspectRatio(contentMode: .fit) - case .symbol(let name): - Image(symbol: name) - .resizable() - .aspectRatio(contentMode: .fit) - case .asset(let name): - Image(name) - .resizable() - .aspectRatio(contentMode: .fit) - case .none: EmptyView() - } - } - .shadow(color: Color(NSColor.black).opacity(0.25), radius: 0.5, y: 0.5) - .padding(2.5) - .foregroundColor(.white) - .frame(width: 20, height: 20) - .background( - RoundedRectangle( - cornerRadius: 5, - style: .continuous - ) - .fill((page.baseColor ?? .white).gradient) - .shadow(color: Color(NSColor.black).opacity(0.25), radius: 0.5, y: 0.5) - ) + FeatureIcon(symbol: symbol, color: page.baseColor, size: 20) } } } diff --git a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift index f778e0e79..325a0b123 100644 --- a/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift +++ b/CodeEdit/Features/Settings/Views/View+NavigationBarBackButtonVisible.swift @@ -21,6 +21,7 @@ struct NavigationBarBackButtonVisible: ViewModifier { self.presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "chevron.left") + .frame(width: 23) } } } From 24658a21aa27779ae07b771c72ef4520573ee565 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 3 Dec 2024 17:37:06 -0600 Subject: [PATCH 24/27] Moved tabs below source control toggle --- .../SourceControlGeneralView.swift | 30 +--------- .../SourceControlGitView.swift | 2 +- .../SourceControlSettingsView.swift | 60 +++++++++++++------ 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift index f25ced7ae..652094d7e 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGeneralView.swift @@ -14,10 +14,7 @@ struct SourceControlGeneralView: View { let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) var body: some View { - SettingsForm { - Section { - sourceControlIsEnabled - } + Group { Section("Source Control") { refreshLocalStatusAuto fetchRefreshStatusAuto @@ -37,30 +34,11 @@ struct SourceControlGeneralView: View { } private extension SourceControlGeneralView { - private var sourceControlIsEnabled: some View { - Toggle( - isOn: $settings.sourceControlIsEnabled - ) { - Label { - Text("Source Control") - Text(""" - Back up your files, collaborate with others, and tag your releases. \ - [Learn more...](https://developer.apple.com/documentation/xcode/source-control-management) - """) - .font(.callout) - } icon: { - FeatureIcon(symbol: Image(symbol: "vault"), color: Color(.systemBlue), size: 26) - } - } - .controlSize(.large) - } - private var refreshLocalStatusAuto: some View { Toggle( "Refresh local status automatically", isOn: $settings.refreshStatusLocally ) - .disabled(!settings.sourceControlIsEnabled) } private var fetchRefreshStatusAuto: some View { @@ -68,7 +46,6 @@ private extension SourceControlGeneralView { "Fetch and refresh server status automatically", isOn: $settings.fetchRefreshServerStatus ) - .disabled(!settings.sourceControlIsEnabled) } private var addRemoveFilesAuto: some View { @@ -76,7 +53,6 @@ private extension SourceControlGeneralView { "Add and remove files automatically", isOn: $settings.addRemoveAutomatically ) - .disabled(!settings.sourceControlIsEnabled) } private var selectFilesToCommitAuto: some View { @@ -84,7 +60,6 @@ private extension SourceControlGeneralView { "Select files to commit automatically", isOn: $settings.selectFilesToCommit ) - .disabled(!settings.sourceControlIsEnabled) } private var showSourceControlChanges: some View { @@ -92,7 +67,6 @@ private extension SourceControlGeneralView { "Show source control changes", isOn: $settings.showSourceControlChanges ) - .disabled(!settings.sourceControlIsEnabled) } private var includeUpstreamChanges: some View { @@ -100,7 +74,7 @@ private extension SourceControlGeneralView { "Include upstream changes", isOn: $settings.includeUpstreamChanges ) - .disabled(!settings.sourceControlIsEnabled || !settings.showSourceControlChanges) + .disabled(!settings.showSourceControlChanges) } private var comparisonView: some View { diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index cff64571b..ce3c87d3b 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -21,7 +21,7 @@ struct SourceControlGitView: View { @State private var resolvedGitIgnorePath: String = "~/.gitignore_global" var body: some View { - SettingsForm { + Group { Section { gitAuthorName gitEmail diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift index 120ead4a1..22f0304ca 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlSettingsView.swift @@ -8,29 +8,55 @@ import SwiftUI struct SourceControlSettingsView: View { + @AppSettings(\.sourceControl.general) + var settings + @State var selectedTab: String = "general" var body: some View { - Group { - switch selectedTab { - case "general": - SourceControlGeneralView() - case "git": - SourceControlGitView() - default: - SourceControlGeneralView() + SettingsForm { + Section { + sourceControlIsEnabled + } footer: { + if settings.sourceControlIsEnabled { + Picker("", selection: $selectedTab) { + Text("General").tag("general") + Text("Git").tag("git") + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.top, 10) + } } - } - .safeAreaInset(edge: .top, spacing: 0) { - Picker("", selection: $selectedTab) { - Text("General").tag("general") - Text("Git").tag("git") + if settings.sourceControlIsEnabled { + switch selectedTab { + case "general": + SourceControlGeneralView() + case "git": + SourceControlGitView() + default: + SourceControlGeneralView() + } } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 20) - .padding(.bottom, 20) + } + } + private var sourceControlIsEnabled: some View { + Toggle( + isOn: $settings.sourceControlIsEnabled + ) { + Label { + Text("Source Control") + Text(""" + Back up your files, collaborate with others, and tag your releases. \ + [Learn more...](https://developer.apple.com/documentation/xcode/source-control-management) + """) + .font(.callout) + } icon: { + FeatureIcon(symbol: Image(symbol: "vault"), color: Color(.systemBlue), size: 26) + } } + .controlSize(.large) } + } From 38cd1f05520d64d3e8c8148bbe52edc37b4efa52 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 4 Dec 2024 15:52:01 -0600 Subject: [PATCH 25/27] Fixed PR issue --- CodeEdit/WorkspaceView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index 6782a8cf6..5327ffc01 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -134,7 +134,7 @@ struct WorkspaceView: View { } } .onChange(of: sourceControlIsEnabled) { newValue in - if !newValue { + if newValue { Task { await sourceControlManager.refreshCurrentBranch() } From 35be9a951b2312e6ba278dbe34fe59f2c44abf0a Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 7 Dec 2024 23:21:52 -0600 Subject: [PATCH 26/27] PR issues, added documentation --- .../Views/InspectorAreaView.swift | 7 +++ .../Settings/Models/GlobPattern.swift | 8 ++- .../Models/IgnorePatternModel.swift | 57 ++++++++++++------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift index aaecbd0a4..a0c9a9dec 100644 --- a/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift +++ b/CodeEdit/Features/InspectorArea/Views/InspectorAreaView.swift @@ -1,3 +1,10 @@ +// +// InspectorAreaView.swift +// CodeEdit +// +// Created by Austin Condiff on 3/21/22. +// + import SwiftUI struct InspectorAreaView: View { diff --git a/CodeEdit/Features/Settings/Models/GlobPattern.swift b/CodeEdit/Features/Settings/Models/GlobPattern.swift index c843b79a6..7eb16409f 100644 --- a/CodeEdit/Features/Settings/Models/GlobPattern.swift +++ b/CodeEdit/Features/Settings/Models/GlobPattern.swift @@ -7,10 +7,14 @@ import Foundation +/// A simple model that associates a UUID with a glob pattern string. +/// +/// This type does not interpret or validate the glob pattern itself. +/// It is simply an identifier (`id`) and the glob pattern string (`value`) associated with it. struct GlobPattern: Identifiable, Hashable, Decodable, Encodable { - /// Ephimeral UUID used to track its representation in the UI + /// Ephemeral UUID used to uniquely identify this instance in the UI var id = UUID() - /// The Glob Pattern to render + /// The Glob Pattern string var value: String } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index aab221e6c..7042aeda9 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -7,6 +7,7 @@ import Foundation +/// A model to manage Git ignore patterns for a file, including loading, saving, and monitoring changes. class IgnorePatternModel: ObservableObject { @Published var loadingPatterns: Bool = false @Published var patterns: [GlobPattern] = [] { @@ -34,16 +35,18 @@ class IgnorePatternModel: ObservableObject { stopFileMonitor() } + /// Resolves the URL for the Git ignore file. + /// - Returns: The resolved `URL` for the Git ignore file. private func gitIgnoreURL() async throws -> URL { - let excludesfile = try await gitConfig.get(key: "core.excludesfile") ?? "" - if !excludesfile.isEmpty { - if excludesfile.starts(with: "~/") { - let relativePath = String(excludesfile.dropFirst(2)) // Remove "~/" + let excludesFile = try await gitConfig.get(key: "core.excludesfile") ?? "" + if !excludesFile.isEmpty { + if excludesFile.starts(with: "~/") { + let relativePath = String(excludesFile.dropFirst(2)) // Remove "~/" return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(relativePath) - } else if excludesfile.starts(with: "/") { - return URL(fileURLWithPath: excludesfile) // Absolute path + } else if excludesFile.starts(with: "/") { + return URL(fileURLWithPath: excludesFile) // Absolute path } else { - return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesfile) + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(excludesFile) } } else { let defaultPath = ".gitignore_global" @@ -53,6 +56,7 @@ class IgnorePatternModel: ObservableObject { } } + /// Starts monitoring the Git ignore file for changes. private func startFileMonitor() async throws { let fileURL = try await gitIgnoreURL() let fileDescriptor = open(fileURL.path, O_EVTONLY) @@ -79,11 +83,13 @@ class IgnorePatternModel: ObservableObject { source.resume() } + /// Stops monitoring the Git ignore file. private func stopFileMonitor() { fileMonitor?.cancel() fileMonitor = nil } + /// Loads patterns from the Git ignore file into the `patterns` property. func loadPatterns() async { await MainActor.run { loadingPatterns = true } // Ensure `loadingPatterns` is updated on the main thread @@ -122,10 +128,14 @@ class IgnorePatternModel: ObservableObject { } } + /// Retrieves the pattern associated with a specific UUID. + /// - Parameter id: The UUID of the pattern to retrieve. + /// - Returns: The matching `GlobPattern`, if found. func getPattern(for id: UUID) -> GlobPattern? { return patterns.first(where: { $0.id == id }) } + /// Saves the current patterns back to the Git ignore file. func savePatterns() { Task { stopFileMonitor() @@ -134,7 +144,7 @@ class IgnorePatternModel: ObservableObject { do { let fileURL = try await gitIgnoreURL() guard let fileContent = try? String(contentsOf: fileURL) else { - writeAllPatterns() + await writeAllPatterns() return } @@ -156,6 +166,7 @@ class IgnorePatternModel: ObservableObject { } } + /// Maps lines to patterns and non-pattern lines (e.g., comments or whitespace). private func mapLines(_ lines: [String]) -> ([String: Int], [(line: String, index: Int)]) { var patternToLineIndex: [String: Int] = [:] var nonPatternLines: [(line: String, index: Int)] = [] @@ -172,6 +183,7 @@ class IgnorePatternModel: ObservableObject { return (patternToLineIndex, nonPatternLines) } + /// Extracts global comments from the non-pattern lines. private func extractGlobalComments( _ nonPatternLines: [(line: String, index: Int)], _ patternToLineIndex: [String: Int] @@ -180,6 +192,7 @@ class IgnorePatternModel: ObservableObject { return globalComments.map(\.line) } + /// Reorders patterns while preserving associated comments and whitespace. private func reorderPatterns( _ globalCommentLines: [String], _ patternToLineIndex: [String: Int], @@ -227,22 +240,22 @@ class IgnorePatternModel: ObservableObject { return reorderedLines } - private func writeAllPatterns() { - Task { - do { - let fileURL = try await gitIgnoreURL() - if !FileManager.default.fileExists(atPath: fileURL.path) { - FileManager.default.createFile(atPath: fileURL.path, contents: nil) - } - - let content = patterns.map(\.value).joined(separator: "\n") - try content.write(to: fileURL, atomically: true, encoding: .utf8) - } catch { - print("Failed to write all patterns: \(error)") + /// Writes all patterns to the Git ignore file. + private func writeAllPatterns() async { + do { + let fileURL = try await gitIgnoreURL() + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) } + + let content = patterns.map(\.value).joined(separator: "\n") + try content.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + print("Failed to write all patterns: \(error)") } } + /// Cleans up extra whitespace from lines. private func cleanUpWhitespace(in lines: [String]) -> [String] { var cleanedLines: [String] = [] var previousLineWasBlank = false @@ -269,11 +282,13 @@ class IgnorePatternModel: ObservableObject { return cleanedLines } + /// Adds a new, empty pattern to the list of patterns. @MainActor func addPattern() { patterns.append(GlobPattern(value: "")) } - + /// Removes the specified patterns from the list of patterns. + /// - Parameter selection: The set of UUIDs for the patterns to remove. If `nil`, no patterns are removed. @MainActor func removePatterns(_ selection: Set? = nil) { let patternsToRemove = selection?.compactMap { getPattern(for: $0) } ?? [] From 97bbefbcb50449ed918e242a3e6f180a2b1ea59f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sun, 8 Dec 2024 00:01:54 -0600 Subject: [PATCH 27/27] Marked IgnorePatternModel with @MainActor. More documentation. Refactored Limiter to use Timer API and removed unused throttle method. --- .../Models/IgnorePatternModel.swift | 62 +++++++++++-------- .../SourceControlGitView.swift | 2 + CodeEdit/Utils/Limiter.swift | 29 ++------- 3 files changed, 41 insertions(+), 52 deletions(-) diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift index 7042aeda9..ab0e8231b 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/Models/IgnorePatternModel.swift @@ -8,8 +8,12 @@ import Foundation /// A model to manage Git ignore patterns for a file, including loading, saving, and monitoring changes. +@MainActor class IgnorePatternModel: ObservableObject { + /// Indicates whether patterns are currently being loaded from the Git ignore file. @Published var loadingPatterns: Bool = false + + /// A collection of Git ignore patterns being managed by this model. @Published var patterns: [GlobPattern] = [] { didSet { if !loadingPatterns { @@ -19,11 +23,19 @@ class IgnorePatternModel: ObservableObject { } } } + + /// Tracks the selected patterns by their unique identifiers (UUIDs). @Published var selection: Set = [] + /// A client for interacting with the Git configuration. private let gitConfig = GitConfigClient(shellClient: currentWorld.shellClient) + + /// A file system monitor for detecting changes to the Git ignore file. private var fileMonitor: DispatchSourceFileSystemObject? + /// Task tracking the current save operation + private var savingTask: Task? + init() { Task { try? await startFileMonitor() @@ -32,7 +44,9 @@ class IgnorePatternModel: ObservableObject { } deinit { - stopFileMonitor() + Task { @MainActor [weak self] in + self?.stopFileMonitor() + } } /// Resolves the URL for the Git ignore file. @@ -69,9 +83,7 @@ class IgnorePatternModel: ObservableObject { ) source.setEventHandler { - Task { - await self.loadPatterns() - } + Task { await self.loadPatterns() } } source.setCancelHandler { @@ -91,40 +103,30 @@ class IgnorePatternModel: ObservableObject { /// Loads patterns from the Git ignore file into the `patterns` property. func loadPatterns() async { - await MainActor.run { loadingPatterns = true } // Ensure `loadingPatterns` is updated on the main thread + loadingPatterns = true do { let fileURL = try await gitIgnoreURL() guard FileManager.default.fileExists(atPath: fileURL.path) else { - await MainActor.run { - patterns = [] - loadingPatterns = false // Update on the main thread - } + patterns = [] + loadingPatterns = false return } if let content = try? String(contentsOf: fileURL) { - let parsedPatterns = content.split(separator: "\n") + patterns = content.split(separator: "\n") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && !$0.starts(with: "#") } .map { GlobPattern(value: String($0)) } - - await MainActor.run { - patterns = parsedPatterns // Update `patterns` on the main thread - loadingPatterns = false // Ensure `loadingPatterns` is updated on the main thread - } + loadingPatterns = false } else { - await MainActor.run { - patterns = [] - loadingPatterns = false - } - } - } catch { - print("Error loading patterns: \(error)") - await MainActor.run { patterns = [] loadingPatterns = false } + } catch { + print("Error loading patterns: \(error)") + patterns = [] + loadingPatterns = false } } @@ -137,9 +139,16 @@ class IgnorePatternModel: ObservableObject { /// Saves the current patterns back to the Git ignore file. func savePatterns() { - Task { + // Cancel the existing task if it exists + savingTask?.cancel() + + // Start a new task for saving patterns + savingTask = Task { stopFileMonitor() - defer { Task { try? await startFileMonitor() } } + defer { + savingTask = nil // Clear the task when done + Task { try? await startFileMonitor() } + } do { let fileURL = try await gitIgnoreURL() @@ -283,13 +292,12 @@ class IgnorePatternModel: ObservableObject { } /// Adds a new, empty pattern to the list of patterns. - @MainActor func addPattern() { patterns.append(GlobPattern(value: "")) } + /// Removes the specified patterns from the list of patterns. /// - Parameter selection: The set of UUIDs for the patterns to remove. If `nil`, no patterns are removed. - @MainActor func removePatterns(_ selection: Set? = nil) { let patternsToRemove = selection?.compactMap { getPattern(for: $0) } ?? [] patterns.removeAll { patternsToRemove.contains($0) } diff --git a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift index ce3c87d3b..7ba696434 100644 --- a/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift +++ b/CodeEdit/Features/Settings/Pages/SourceControlSettings/SourceControlGitView.swift @@ -55,6 +55,8 @@ struct SourceControlGitView: View { } } .onAppear { + // Intentionally using an onAppear with a Task instead of just a .task modifier. + // When we did this it was executing too often. Task { authorName = try await gitConfig.get(key: "user.name", global: true) ?? "" authorEmail = try await gitConfig.get(key: "user.email", global: true) ?? "" diff --git a/CodeEdit/Utils/Limiter.swift b/CodeEdit/Utils/Limiter.swift index 154a82757..6101e186c 100644 --- a/CodeEdit/Utils/Limiter.swift +++ b/CodeEdit/Utils/Limiter.swift @@ -11,7 +11,7 @@ import Foundation // TODO: Look into improving this API by using async by default so `Task` isn't needed when used. enum Limiter { // Keep track of debounce timers and throttle states - private static var debounceTimers: [AnyHashable: AnyCancellable] = [:] + private static var debounceTimers: [AnyHashable: Timer] = [:] private static var throttleLastExecution: [AnyHashable: Date] = [:] /// Debounces an action with a specified duration and identifier. @@ -21,31 +21,10 @@ enum Limiter { /// - action: The action to be executed after the debounce period. static func debounce(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) { // Cancel any existing debounce timer for the given ID - debounceTimers[id]?.cancel() - + debounceTimers[id]?.invalidate() // Start a new debounce timer for the given ID - debounceTimers[id] = Timer.publish(every: duration, on: .main, in: .common) - .autoconnect() - .first() - .sink { _ in - action() - debounceTimers[id] = nil - } - } - - /// Throttles an action with a specified duration and identifier. - /// - Parameters: - /// - id: A unique identifier for the throttled action. - /// - duration: The throttle duration in seconds. - /// - action: The action to be executed after the throttle period. - static func throttle(id: AnyHashable, duration: TimeInterval, action: @escaping () -> Void) { - // Check the time of the last execution for the given ID - if let lastExecution = throttleLastExecution[id], Date().timeIntervalSince(lastExecution) < duration { - return // Skip this call if it's within the throttle duration + debounceTimers[id] = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in + action() } - - // Update the last execution time and perform the action - throttleLastExecution[id] = Date() - action() } }