diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index caa5927e6..b359c60e3 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -281,6 +281,13 @@ 61538B902B111FE800A88846 /* String+AppearancesOfSubstring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61538B8F2B111FE800A88846 /* String+AppearancesOfSubstring.swift */; }; 61538B932B11201900A88846 /* String+Character.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61538B922B11201900A88846 /* String+Character.swift */; }; 615AA21A2B0CFD480013FCCC /* LazyStringLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615AA2192B0CFD480013FCCC /* LazyStringLoader.swift */; }; + 617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3CF2C25AFAE00B58BFE /* TaskNotificationHandler.swift */; }; + 617DB3D32C25AFEA00B58BFE /* TaskNotificationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3D22C25AFEA00B58BFE /* TaskNotificationModel.swift */; }; + 617DB3D62C25B02D00B58BFE /* TaskNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3D52C25B02D00B58BFE /* TaskNotificationView.swift */; }; + 617DB3D82C25B04D00B58BFE /* CustomLoadingRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3D72C25B04D00B58BFE /* CustomLoadingRingView.swift */; }; + 617DB3DA2C25B07F00B58BFE /* TaskNotificationsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3D92C25B07F00B58BFE /* TaskNotificationsDetailView.swift */; }; + 617DB3DC2C25B14A00B58BFE /* ActivityViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3DB2C25B14A00B58BFE /* ActivityViewer.swift */; }; + 617DB3DF2C25E13800B58BFE /* TaskNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3DE2C25E13800B58BFE /* TaskNotificationHandlerTests.swift */; }; 6195E30D2B64044F007261CA /* WorkspaceDocument+SearchState+FindTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6195E30C2B64044F007261CA /* WorkspaceDocument+SearchState+FindTests.swift */; }; 6195E30F2B640474007261CA /* WorkspaceDocument+SearchState+FindAndReplaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6195E30E2B640474007261CA /* WorkspaceDocument+SearchState+FindAndReplaceTests.swift */; }; 6195E3112B640485007261CA /* WorkspaceDocument+SearchState+IndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6195E3102B640485007261CA /* WorkspaceDocument+SearchState+IndexTests.swift */; }; @@ -863,6 +870,13 @@ 61538B8F2B111FE800A88846 /* String+AppearancesOfSubstring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+AppearancesOfSubstring.swift"; sourceTree = ""; }; 61538B922B11201900A88846 /* String+Character.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Character.swift"; sourceTree = ""; }; 615AA2192B0CFD480013FCCC /* LazyStringLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyStringLoader.swift; sourceTree = ""; }; + 617DB3CF2C25AFAE00B58BFE /* TaskNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationHandler.swift; sourceTree = ""; }; + 617DB3D22C25AFEA00B58BFE /* TaskNotificationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationModel.swift; sourceTree = ""; }; + 617DB3D52C25B02D00B58BFE /* TaskNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationView.swift; sourceTree = ""; }; + 617DB3D72C25B04D00B58BFE /* CustomLoadingRingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLoadingRingView.swift; sourceTree = ""; }; + 617DB3D92C25B07F00B58BFE /* TaskNotificationsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationsDetailView.swift; sourceTree = ""; }; + 617DB3DB2C25B14A00B58BFE /* ActivityViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityViewer.swift; sourceTree = ""; }; + 617DB3DE2C25E13800B58BFE /* TaskNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationHandlerTests.swift; sourceTree = ""; }; 6195E30C2B64044F007261CA /* WorkspaceDocument+SearchState+FindTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+SearchState+FindTests.swift"; sourceTree = ""; }; 6195E30E2B640474007261CA /* WorkspaceDocument+SearchState+FindAndReplaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+SearchState+FindAndReplaceTests.swift"; sourceTree = ""; }; 6195E3102B640485007261CA /* WorkspaceDocument+SearchState+IndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+SearchState+IndexTests.swift"; sourceTree = ""; }; @@ -1461,6 +1475,7 @@ children = ( 582213EE2918345500EFE361 /* About */, 588847642992A30900996D95 /* CEWorkspace */, + 617DB3CE2C25AF5B00B58BFE /* ActivityViewer */, 587B9D7529300ABD00AC7927 /* CodeEditUI */, 58FD7603291EA1CB0051D6E4 /* Commands */, 043C321227E31FE8006AE443 /* Documents */, @@ -1787,6 +1802,7 @@ children = ( 283BDCC22972F211002AFF81 /* Acknowledgements */, 4EE96EC82960562000FFBEA8 /* Documents */, + 617DB3DD2C25E11500B58BFE /* ActivityViewer */, 583E527429361B39001AB554 /* CodeEditUI */, 587B612C2934199800D5CD8F /* CodeFile */, 613899BD2B6E70E200A5CAF6 /* Search */, @@ -2382,6 +2398,35 @@ path = FuzzySearch; sourceTree = ""; }; + 617DB3CE2C25AF5B00B58BFE /* ActivityViewer */ = { + isa = PBXGroup; + children = ( + 617DB3DB2C25B14A00B58BFE /* ActivityViewer.swift */, + 617DB3D52C25B02D00B58BFE /* TaskNotificationView.swift */, + 617DB3D92C25B07F00B58BFE /* TaskNotificationsDetailView.swift */, + 617DB3D72C25B04D00B58BFE /* CustomLoadingRingView.swift */, + 617DB3CF2C25AFAE00B58BFE /* TaskNotificationHandler.swift */, + 617DB3D12C25AFD300B58BFE /* Models */, + ); + path = ActivityViewer; + sourceTree = ""; + }; + 617DB3D12C25AFD300B58BFE /* Models */ = { + isa = PBXGroup; + children = ( + 617DB3D22C25AFEA00B58BFE /* TaskNotificationModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + 617DB3DD2C25E11500B58BFE /* ActivityViewer */ = { + isa = PBXGroup; + children = ( + 617DB3DE2C25E13800B58BFE /* TaskNotificationHandlerTests.swift */, + ); + path = ActivityViewer; + sourceTree = ""; + }; 66AF6CE02BF17CB100D83C9D /* ViewModels */ = { isa = PBXGroup; children = ( @@ -3403,6 +3448,7 @@ 04BA7C142AE2AA7300584E1C /* GitCloneViewModel.swift in Sources */, B61A606129F188AB009B43F9 /* ExternalLink.swift in Sources */, 587B9E9729301D8F00AC7927 /* BitBucketAccount+Token.swift in Sources */, + 617DB3D62C25B02D00B58BFE /* TaskNotificationView.swift in Sources */, 587B9E7729301D8F00AC7927 /* String+PercentEncoding.swift in Sources */, 587B9E5B29301D8F00AC7927 /* GitCheckoutBranchView.swift in Sources */, 2813F93827ECC4AA00E305E4 /* FindNavigatorResultList.swift in Sources */, @@ -3484,6 +3530,7 @@ 58798284292ED0FB0085B254 /* TerminalEmulatorView.swift in Sources */, B6C4F2AC2B3CC4D000B2B140 /* CommitChangedFileListItemView.swift in Sources */, 6C82D6B329BFD88700495C54 /* NavigateCommands.swift in Sources */, + 617DB3D82C25B04D00B58BFE /* CustomLoadingRingView.swift in Sources */, B6CFD8112C20A8EE00E63F1A /* NSFont+WithWeight.swift in Sources */, B66A4E4C29C9179B004573B4 /* CodeEditApp.swift in Sources */, 661EF7B82BEE215300C3E577 /* ImageFileView.swift in Sources */, @@ -3605,6 +3652,7 @@ 581550D029FBD30400684881 /* FileSystemTableViewCell.swift in Sources */, B607183F2B17DB07009CDAB4 /* SourceControlNavigatorRepositoryView+contextMenu.swift in Sources */, B62AEDD42A27B29F009A9F52 /* PaneToolbar.swift in Sources */, + 617DB3D32C25AFEA00B58BFE /* TaskNotificationModel.swift in Sources */, D7E201B227E8D50000CB86D0 /* FindNavigatorForm.swift in Sources */, 287776E927E34BC700D46668 /* EditorTabBarView.swift in Sources */, B60BE8BD297A167600841125 /* AcknowledgementRowView.swift in Sources */, @@ -3634,6 +3682,7 @@ 613899B52B6E700300A5CAF6 /* FuzzySearchModels.swift in Sources */, 58D01C94293167DC00C5B6B4 /* Color+HEX.swift in Sources */, 6C578D8729CD345900DC73B2 /* ExtensionSceneView.swift in Sources */, + 617DB3D02C25AFAE00B58BFE /* TaskNotificationHandler.swift in Sources */, B640A9A129E2188F00715F20 /* View+NavigationBarBackButtonVisible.swift in Sources */, 587B9E7929301D8F00AC7927 /* GitHubIssueRouter.swift in Sources */, 587B9E8029301D8F00AC7927 /* GitHubConfiguration.swift in Sources */, @@ -3700,6 +3749,7 @@ 58798218292D92370085B254 /* String+SafeOffset.swift in Sources */, 6C6BD70429CD17B600235D17 /* ExtensionsManager.swift in Sources */, 587B9E6129301D8F00AC7927 /* GitLabOAuthConfiguration.swift in Sources */, + 617DB3DC2C25B14A00B58BFE /* ActivityViewer.swift in Sources */, 587B9E6229301D8F00AC7927 /* GitLabConfiguration.swift in Sources */, 61A53A7E2B4449870093BF8A /* WorkspaceDocument+Find.swift in Sources */, 6CABB19E29C5591D00340467 /* NSTableViewWrapper.swift in Sources */, @@ -3810,6 +3860,7 @@ 04C3255B2801F86400C8DA2D /* ProjectNavigatorViewController.swift in Sources */, 587B9E6029301D8F00AC7927 /* GitLabOAuthRouter.swift in Sources */, B6AB09B32AB919CF0003A3A6 /* View+actionBar.swift in Sources */, + 617DB3DA2C25B07F00B58BFE /* TaskNotificationsDetailView.swift in Sources */, 6C05A8AF284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift in Sources */, 588847632992A2A200996D95 /* CEWorkspaceFile.swift in Sources */, 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */, @@ -3825,6 +3876,7 @@ files = ( 583E528C29361B39001AB554 /* CodeEditUITests.swift in Sources */, 613053652B23A49300D767E3 /* TemporaryFile.swift in Sources */, + 617DB3DF2C25E13800B58BFE /* TaskNotificationHandlerTests.swift in Sources */, 587B60F82934124200D5CD8F /* CEWorkspaceFileManagerTests.swift in Sources */, 6130535F2B23A31300D767E3 /* MemorySearchTests.swift in Sources */, 587B61012934170A00D5CD8F /* UnitTests_Extensions.swift in Sources */, diff --git a/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift b/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift index ad2660055..b4958a416 100644 --- a/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift +++ b/CodeEdit/Features/About/Acknowledgements/ViewModels/AcknowledgementsViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI final class AcknowledgementsViewModel: ObservableObject { - @Published private (set) var acknowledgements: [AcknowledgementDependency] + @Published private(set) var acknowledgements: [AcknowledgementDependency] var indexedAcknowledgements: [(index: Int, acknowledgement: AcknowledgementDependency)] { return Array(zip(acknowledgements.indices, acknowledgements)) diff --git a/CodeEdit/Features/ActivityViewer/ActivityViewer.swift b/CodeEdit/Features/ActivityViewer/ActivityViewer.swift new file mode 100644 index 000000000..51cd6650b --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/ActivityViewer.swift @@ -0,0 +1,42 @@ +// +// ActivityViewer.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import SwiftUI + +/// A view that shows the activity bar and the current status of any executed task +struct ActivityViewer: View { + @Environment(\.colorScheme) + var colorScheme + + @ObservedObject var taskNotificationHandler: TaskNotificationHandler + var body: some View { + HStack { + HStack(spacing: 0) { + // This is only a placeholder for the task popover(coming in the next pr) + Rectangle() + .frame(height: 22) + .hidden() + + Spacer() + + TaskNotificationView(taskNotificationHandler: taskNotificationHandler) + } + .padding(.horizontal, 10) + .background { + if colorScheme == .dark { + RoundedRectangle(cornerRadius: 5) + .opacity(0.10) + } else { + RoundedRectangle(cornerRadius: 5) + .opacity(0.1) + } + } + .frame(minWidth: 200, idealWidth: 680) + } + .frame(height: 22) + } +} diff --git a/CodeEdit/Features/ActivityViewer/CustomLoadingRingView.swift b/CodeEdit/Features/ActivityViewer/CustomLoadingRingView.swift new file mode 100644 index 000000000..a7d04f087 --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/CustomLoadingRingView.swift @@ -0,0 +1,62 @@ +// +// CustomLoadingRingView.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import SwiftUI + +struct CustomLoadingRingView: View { + @State private var isAnimating = false + @State private var previousValue: Bool = false + var progress: Double? + var currentTaskCount: Int + + let lineWidth: CGFloat = 2 + var body: some View { + Circle() + .stroke(style: StrokeStyle(lineWidth: lineWidth)) + .foregroundStyle(.tertiary) + .overlay { + if let progress = progress { + Circle() + .trim(from: 0, to: progress) + .stroke(Color.blue.gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .animation(.easeInOut, value: progress) + } else { + Circle() + .trim(from: 0, to: 0.5) + .stroke(Color.blue.gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect( + previousValue ? + .degrees(isAnimating ? 0 : -360) + : .degrees(isAnimating ? 360 : 0) + ) + .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false), value: isAnimating) + .onAppear { + self.previousValue = isAnimating + self.isAnimating.toggle() + } + } + } + .rotationEffect(.degrees(-90)) + .overlay { + if currentTaskCount > 1 { + Text("\(currentTaskCount)") + .font(.caption) + } + } + } +} + +#Preview { + Group { + CustomLoadingRingView(currentTaskCount: 1) + .frame(width: 22, height: 22) + + CustomLoadingRingView(progress: 0.65, currentTaskCount: 1) + .frame(width: 22, height: 22) + } + .padding() +} diff --git a/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift b/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift new file mode 100644 index 000000000..a92b618bd --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift @@ -0,0 +1,17 @@ +// +// TaskNotificationModel.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import Foundation + +/// Represents a notifications or tasks, that are displayed in the activity viewer +struct TaskNotificationModel: Equatable { + var id: String + var title: String + var message: String? + var percentage: Double? + var isLoading: Bool = false +} diff --git a/CodeEdit/Features/ActivityViewer/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/TaskNotificationHandler.swift new file mode 100644 index 000000000..8685ec7eb --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/TaskNotificationHandler.swift @@ -0,0 +1,207 @@ +// +// TaskNotificationHandler.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import Foundation +import Combine + +/// Manages task-related notifications. +/// +/// This class listens for notifications named `.taskNotification` and performs actions +/// such as creating, updating, or deleting tasks based on the notification's content. +/// +/// When a task is created, it is added to the end of the array. The activity viewer displays +/// only the first item in the array. To immediately display a notification, use the +/// `"action": "createWithPriority"` option to insert the task at the beginning of the array. +/// *Note: This option should be reserved for important notifications only.* +/// +/// It is recommended to use `UUID().uuidString` to generate a unique identifier for each task. +/// This identifier can then be used to update or delete the task. Alternatively, you can use any +/// unique identifier, such as a token sent from a language server. +/// +/// Remember to manage your task notifications appropriately. You should either delete task +/// notifications manually or schedule their deletion in advance using the `deleteWithDelay` method. +/// +/// ## Available Methods +/// - `create`: +/// Creates a new Task Notification. +/// Required fields: `id` (String), `action` (String), `title` (String). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// - `createWithPriority`: +/// Creates a new Task Notification and inserts it at the start of the array. +/// This ensures it appears in the activity viewer even if there are other task notifications before it. +/// **Note:** This should only be used for important notifications! +/// Required fields: `id` (String), `action` (String), `title` (String). +/// Optional fields: `message` (String), `percentage` (Double), `isLoading` (Bool). +/// - `update`: +/// Updates an existing task notification. It's important to pass the same `id` to update the correct task. +/// Required fields: `id` (String), `action` (String). +/// Optional fields: `title` (String), `message` (String), `percentage` (Double), `isLoading` (Bool). +/// - `delete`: +/// Deletes an existing task notification. +/// Required fields: `id` (String), `action` (String). +/// - `deleteWithDelay`: +/// Deletes an existing task notification after a certain `TimeInterval`. +/// Required fields: `id` (String), `action` (String), `delay` (Double). +/// **Important:** When specifying the delay, ensure it's a double. +/// For example, '2' would be invalid because it would count as an integer, use '2.0' instead. +/// +/// ## Example Usage: +/// ```swift +/// let uuidString = UUID().uuidString +/// +/// func createTask() { +/// let userInfo: [String: Any] = [ +/// "id": "uniqueTaskID", +/// "action": "create", +/// "title": "Task Title" +/// ] +/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) +/// } +/// +/// func createTaskWithPriority() { +/// let userInfo: [String: Any] = [ +/// "id": "uniqueTaskID", +/// "action": "createWithPriority", +/// "title": "Priority Task Title" +/// ] +/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) +/// } +/// +/// func updateTask() { +/// var userInfo: [String: Any] = [ +/// "id": "uniqueTaskID", +/// "action": "update", +/// "title": "Updated Task Title", +/// "message": "Updated Task Message" +/// "percentage": 0.5, +/// "isLoading": true +/// ] +/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) +/// } +/// +/// func deleteTask() { +/// let userInfo: [String: Any] = [ +/// "id": "uniqueTaskID", +/// "action": "delete" +/// ] +/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) +/// } +/// +/// func deleteTaskWithDelay() { +/// let userInfo: [String: Any] = [ +/// "id": "uniqueTaskID", +/// "action": "deleteWithDelay", +/// "delay": 4.0 // 4 would be invalid, because it would count as an int +/// ] +/// NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) +/// } +/// ``` +/// +/// - Important: Please refer to ``CodeEdit/TaskNotificationModel`` and ensure you pass the correct values. +final class TaskNotificationHandler: ObservableObject { + @Published private(set) var notifications: [TaskNotificationModel] = [] + var cancellables: Set = [] + + /// Initialises a new `TaskNotificationHandler` and starts observing for task notifications. + init() { + NotificationCenter.default + .publisher(for: .taskNotification) + .receive(on: DispatchQueue.main) + .sink { notification in + self.handleNotification(notification) + } + .store(in: &cancellables) + } + + deinit { + NotificationCenter.default.removeObserver(self, name: .taskNotification, object: nil) + } + + /// Handles notifications about task events. + /// + /// - Parameter notification: The notification containing task information. + private func handleNotification(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let taskID = userInfo["id"] as? String, + let action = userInfo["action"] as? String else { return } + + switch action { + case "create", "createWithPriority": + createTask(task: userInfo) + case "update": + updateTask(task: userInfo) + case "delete": + deleteTask(taskID: taskID) + case "deleteWithDelay": + if let delay = userInfo["delay"] as? Double { + deleteTaskAfterDelay(taskID: taskID, delay: delay) + } + default: + break + } + } + + /// Creates a new task or inserts it at the beginning of the tasks array based on the action. + /// + /// - Parameter task: A dictionary containing task information. + private func createTask(task: [AnyHashable: Any]) { + guard let title = task["title"] as? String, + let id = task["id"] as? String, + let action = task["action"] as? String else { + return + } + + let task = TaskNotificationModel( + id: id, + title: title, + message: task["message"] as? String, + percentage: task["percentage"] as? Double, + isLoading: task["isLoading"] as? Bool ?? false + ) + + if action == "create" { + notifications.append(task) + } else { + notifications.insert(task, at: 0) + } + } + + /// Updates an existing task with new information. + /// + /// - Parameter task: A dictionary containing task information. + private func updateTask(task: [AnyHashable: Any]) { + guard let taskID = task["id"] as? String else { return } + if let index = self.notifications.firstIndex(where: { $0.id == taskID }) { + if let title = task["title"] as? String { + self.notifications[index].title = title + } + if let message = task["message"] as? String { + self.notifications[index].message = message + } + if let percentage = task["percentage"] as? Double { + self.notifications[index].percentage = percentage + } + if let isLoading = task["isLoading"] as? Bool { + self.notifications[index].isLoading = isLoading + } + } + } + + private func deleteTask(taskID: String) { + self.notifications.removeAll { $0.id == taskID } + } + + private func deleteTaskAfterDelay(taskID: String, delay: Double) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.notifications.removeAll { $0.id == taskID } + } + } +} + +extension Notification.Name { + static let taskNotification = Notification.Name("taskNotification") +} diff --git a/CodeEdit/Features/ActivityViewer/TaskNotificationView.swift b/CodeEdit/Features/ActivityViewer/TaskNotificationView.swift new file mode 100644 index 000000000..cb8b5f892 --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/TaskNotificationView.swift @@ -0,0 +1,62 @@ +// +// TaskNotificationView.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import SwiftUI + +struct TaskNotificationView: View { + @ObservedObject var taskNotificationHandler: TaskNotificationHandler + @State private var hovered: Bool = false + @State private var isPresented: Bool = false + + var body: some View { + if let notification = taskNotificationHandler.notifications.first { + HStack { + Text(notification.title) + .font(.subheadline) + + if notification.isLoading { + CustomLoadingRingView( + progress: notification.percentage, + currentTaskCount: taskNotificationHandler.notifications.count + ) + .padding(.leading, 5) + .frame(height: 15) + } else { + if taskNotificationHandler.notifications.count > 1 { + Text("\(taskNotificationHandler.notifications.count)") + .background( + Circle() + .foregroundStyle(.gray) + ) + } + } + } + .animation(.easeInOut, value: notification) + .padding(3) + .background { + if hovered { + RoundedRectangle(cornerRadius: 5) + .tint(.gray) + .foregroundStyle(.ultraThickMaterial) + } + } + .onHover { isHovered in + self.hovered = isHovered + } + .padding(-3) + .popover(isPresented: $isPresented) { + TaskNotificationsDetailView(taskNotificationHandler: taskNotificationHandler) + }.onTapGesture { + self.isPresented.toggle() + } + } + } +} + +#Preview { + TaskNotificationView(taskNotificationHandler: TaskNotificationHandler()) +} diff --git a/CodeEdit/Features/ActivityViewer/TaskNotificationsDetailView.swift b/CodeEdit/Features/ActivityViewer/TaskNotificationsDetailView.swift new file mode 100644 index 000000000..9f11d20d4 --- /dev/null +++ b/CodeEdit/Features/ActivityViewer/TaskNotificationsDetailView.swift @@ -0,0 +1,108 @@ +// +// TaskNotificationsDetailView.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import SwiftUI + +struct TaskNotificationsDetailView: View { + @ObservedObject var taskNotificationHandler: TaskNotificationHandler + @State private var selectedTaskNotificationIndex: Int = 0 + var body: some View { + ScrollView { + VStack(alignment: .leading) { + if let selected = + taskNotificationHandler + .notifications[safe: selectedTaskNotificationIndex] { + Text(selected.title) + .font(.headline) + + Text(selected.id) + .foregroundStyle(.secondary) + .font(.system(size: 8)) + + Divider() + .padding(.vertical, 5) + + if let message = selected.message, !message.isEmpty { + Text(message) + .fixedSize(horizontal: false, vertical: true) + .transition(.identity) + } else { + Text("No Details") + } + + if selected.isLoading { + if let percentage = selected.percentage { + ProgressView(value: percentage) { + // Text("Progress") + } currentValueLabel: { + Text("\(String(format: "%.0f", percentage * 100))%") + }.padding(.top, 5) + } else { + ProgressView() + .progressViewStyle(.linear) + .padding(.top, 5) + } + } + + Spacer() + Divider() + + HStack { + Button(action: { + withAnimation { + selectedTaskNotificationIndex -= 1 + } + }, label: { + Image(systemName: "chevron.left") + }) + .disabled( + selectedTaskNotificationIndex - 1 < 0 + ) + + Spacer() + + Text("\(selectedTaskNotificationIndex + 1)") + + Spacer() + + Button(action: { + withAnimation { + selectedTaskNotificationIndex += 1 + } + }, label: { + Image(systemName: "chevron.right") + }) + .disabled( + selectedTaskNotificationIndex + 1 == taskNotificationHandler.notifications.count + ) + }.animation(.spring, value: selected) + } else { + Text("Task done") + .font(.headline) + + Divider() + .padding(.vertical, 5) + + Text("The task has been deleted and is no longer available.") + .fixedSize(horizontal: false, vertical: true) + .transition(.identity) + } + } + } + .padding(5) + .frame(width: 220) + .onChange(of: taskNotificationHandler.notifications) { newValue in + if selectedTaskNotificationIndex >= newValue.count { + selectedTaskNotificationIndex = 0 + } + } + } +} + +#Preview { + TaskNotificationsDetailView(taskNotificationHandler: TaskNotificationHandler()) +} diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index c3d77ea93..ba7b06b3e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -26,6 +26,8 @@ extension CodeEditWindowController { .sidebarTrackingSeparator, .branchPicker, .flexibleSpace, + .activityViewer, + .flexibleSpace, .itemListTrackingSeparator, .flexibleSpace, .toggleLastSidebarItem @@ -39,7 +41,8 @@ extension CodeEditWindowController { .flexibleSpace, .itemListTrackingSeparator, .toggleLastSidebarItem, - .branchPicker + .branchPicker, + .activityViewer ] } @@ -59,6 +62,7 @@ extension CodeEditWindowController { } } + // swiftlint:disable:next function_body_length func toolbar( _ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, @@ -111,7 +115,16 @@ extension CodeEditWindowController { toolbarItem.view = view return toolbarItem + case .activityViewer: + let toolbarItem = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier.activityViewer) + toolbarItem.visibilityPriority = .user + toolbarItem.view = NSHostingView( + rootView: ActivityViewer( + taskNotificationHandler: taskNotificationHandler + ) + ) + return toolbarItem default: return NSToolbarItem(itemIdentifier: itemIdentifier) } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift index 3d496d873..7da694c2e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift @@ -25,11 +25,18 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs var commandPalettePanel: SearchPanel? var navigatorSidebarViewModel: NavigatorSidebarViewModel? + var taskNotificationHandler: TaskNotificationHandler + var splitViewController: NSSplitViewController! internal var cancellables = [AnyCancellable]() - init(window: NSWindow?, workspace: WorkspaceDocument?) { + init( + window: NSWindow?, + workspace: WorkspaceDocument?, + taskNotificationHandler: TaskNotificationHandler + ) { + self.taskNotificationHandler = taskNotificationHandler super.init(window: window) guard let workspace else { return } self.workspace = workspace diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift index 4cf51eb3d..f3bea3c5f 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowControllerExtensions.swift @@ -143,4 +143,5 @@ extension NSToolbarItem.Identifier { static let toggleLastSidebarItem: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ToggleLastSidebarItem") static let itemListTrackingSeparator = NSToolbarItem.Identifier("ItemListTrackingSeparator") static let branchPicker: NSToolbarItem.Identifier = NSToolbarItem.Identifier("BranchPicker") + static let activityViewer: NSToolbarItem.Identifier = NSToolbarItem.Identifier("ActivityViewer") } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument+Index.swift b/CodeEdit/Features/Documents/WorkspaceDocument+Index.swift index 40ca53be7..e84edceaf 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument+Index.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument+Index.swift @@ -11,15 +11,19 @@ extension WorkspaceDocument.SearchState { /// Adds the contents of the current workspace URL to the search index. /// That means that the contents of the workspace will be indexed and searchable. func addProjectToIndex() { - guard let indexer = indexer else { - return - } - - guard let url = workspace.fileURL else { - return - } + guard let indexer = indexer else { return } + guard let url = workspace.fileURL else { return } indexStatus = .indexing(progress: 0.0) + let uuidString = UUID().uuidString + let createInfo: [String: Any] = [ + "id": uuidString, + "action": "create", + "title": "Indexing | Processing files", + "message": "Creating an index to enable fast and accurate searches within your codebase.", + "isLoading": true + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createInfo) Task.detached { let filePaths = self.getFileURLs(at: url) @@ -37,6 +41,12 @@ extension WorkspaceDocument.SearchState { await MainActor.run { self.indexStatus = .indexing(progress: progress) } + let updateInfo: [String: Any] = [ + "id": uuidString, + "action": "update", + "percentage": progress + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) } } asyncController.index.flush() @@ -44,6 +54,20 @@ extension WorkspaceDocument.SearchState { await MainActor.run { self.indexStatus = .done } + let updateInfo: [String: Any] = [ + "id": uuidString, + "action": "update", + "title": "Finished indexing", + "isLoading": false + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) + + let deleteInfo = [ + "id": uuidString, + "action": "deleteWithDelay", + "delay": 4.0 + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) } } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument.swift index 212106c86..c9fccc70d 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument.swift @@ -38,6 +38,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var listenerModel: WorkspaceNotificationModel = .init() var sourceControlManager: SourceControlManager? + var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + private var cancellables = Set() deinit { @@ -89,7 +91,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { } let windowController = CodeEditWindowController( window: window, - workspace: self + workspace: self, + taskNotificationHandler: taskNotificationHandler ) if let rectString = getFromWorkspaceState(.workspaceWindowSize) as? String { diff --git a/CodeEdit/Features/WindowCommands/ViewCommands.swift b/CodeEdit/Features/WindowCommands/ViewCommands.swift index 0d3a933cb..1326a93dc 100644 --- a/CodeEdit/Features/WindowCommands/ViewCommands.swift +++ b/CodeEdit/Features/WindowCommands/ViewCommands.swift @@ -73,7 +73,11 @@ struct ViewCommands: Commands { Divider() HideCommands( - windowController: windowController ?? CodeEditWindowController(window: nil, workspace: nil), + windowController: windowController ?? CodeEditWindowController( + window: nil, + workspace: nil, + taskNotificationHandler: TaskNotificationHandler() + ), utilityAreaModel: windowController?.workspace?.utilityAreaModel ?? UtilityAreaViewModel() ) .onReceive(NSApp.publisher(for: \.keyWindow)) { window in diff --git a/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift b/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift new file mode 100644 index 000000000..0d02c69f9 --- /dev/null +++ b/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift @@ -0,0 +1,135 @@ +// +// TaskNotificationHandlerTests.swift +// CodeEditTests +// +// Created by Tommy Ludwig on 21.06.24. +// + +import XCTest +@testable import CodeEdit + +final class TaskNotificationHandlerTests: XCTestCase { + var taskNotificationHandler: TaskNotificationHandler! + + override func setUp() { + super.setUp() + taskNotificationHandler = TaskNotificationHandler() + } + + override func tearDown() { + taskNotificationHandler = nil + super.tearDown() + } + + func testCreateTask() { + let uuid = UUID().uuidString + let userInfo: [String: Any] = [ + "id": uuid, + "action": "create", + "title": "Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertEqual(self.taskNotificationHandler.notifications.first?.id, uuid) + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } + + func testCreateTaskWithPriority() { + let task1: [String: Any] = [ + "id": UUID().uuidString, + "action": "create", + "title": "Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: task1) + + let task2: [String: Any] = [ + "id": UUID().uuidString, + "action": "createWithPriority", + "title": "Priority Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: task2) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + XCTAssertEqual(self.taskNotificationHandler.notifications.first?.title, "Priority Task Title") + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } + + func testUpdateTask() { + let uuid = UUID().uuidString + let taskInfo: [String: Any] = [ + "id": uuid, + "action": "create", + "title": "Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: taskInfo) + + let taskUpdateInfo: [String: Any] = [ + "id": uuid, + "action": "update", + "title": "Updated Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: taskUpdateInfo) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertEqual(self.taskNotificationHandler.notifications.first?.title, "Updated Task Title") + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } + + func testDeleteTask() { + let uuid = UUID().uuidString + let createUserInfo: [String: Any] = [ + "id": uuid, + "action": "create", + "title": "Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createUserInfo) + let deleteUserInfo: [String: Any] = [ + "id": uuid, + "action": "delete" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteUserInfo) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertTrue(self.taskNotificationHandler.notifications.isEmpty) + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } + + func testDeleteTaskWithDelay() { + let uuid = UUID().uuidString + let createUserInfo: [String: Any] = [ + "id": uuid, + "action": "create", + "title": "Task Title" + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createUserInfo) + let deleteUserInfo: [String: Any] = [ + "id": uuid, + "action": "deleteWithDelay", + "delay": 0.2 + ] + NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteUserInfo) + + let testExpectation = XCTestExpectation() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + XCTAssertFalse(self.taskNotificationHandler.notifications.isEmpty) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertTrue(self.taskNotificationHandler.notifications.isEmpty) + testExpectation.fulfill() + } + wait(for: [testExpectation], timeout: 1) + } +}