diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 494a5c1d5..c518f7d4b 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -29,11 +29,7 @@ 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; }; 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; }; 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */; }; - 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; - 6CAAF69229BCC71C00A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; - 6CAAF69429BCD78600A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB4463F2B6DFF3A00539ED0 /* CodeEditSourceEditor */; }; - 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB94D022CA1205100E8651C /* AsyncAlgorithms */; }; 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */; }; 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; @@ -107,8 +103,6 @@ 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenWithCodeEdit.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 589F3E342936185400E1A4DA /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 58F2EACE292FB2B0004A9BDE /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; - 6C67413D2C44A28C00AABDF5 /* ProjectNavigatorViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+DataSource.swift"; sourceTree = ""; }; - 6C67413F2C44A2A200AABDF5 /* ProjectNavigatorViewController+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+Delegate.swift"; sourceTree = ""; }; 6C9619262C3F285C009733CE /* CodeEditTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CodeEditTestPlan.xctestplan; sourceTree = ""; }; B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CodeEdit.app; sourceTree = BUILT_PRODUCTS_DIR; }; B658FB3D27DA9E1000EA4DBD /* CodeEditTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodeEditTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -215,15 +209,6 @@ name = Frameworks; sourceTree = ""; }; - 6C01F25D2C4820B600AA951B /* Recovered References */ = { - isa = PBXGroup; - children = ( - 6C67413D2C44A28C00AABDF5 /* ProjectNavigatorViewController+DataSource.swift */, - 6C67413F2C44A2A200AABDF5 /* ProjectNavigatorViewController+Delegate.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; B658FB2327DA9E0F00EA4DBD = { isa = PBXGroup; children = ( @@ -239,7 +224,6 @@ 283BDCBC2972EEBD002AFF81 /* Package.resolved */, B658FB2D27DA9E0F00EA4DBD /* Products */, 5C403B8D27E20F8000788241 /* Frameworks */, - 6C01F25D2C4820B600AA951B /* Recovered References */, ); indentWidth = 4; sourceTree = ""; @@ -380,7 +364,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1620; TargetAttributes = { 2BE487EB28245162003F3F64 = { CreatedOnToolsVersion = 13.3.1; @@ -524,11 +508,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6CAAF69429BCD78600A1F48A /* (null) in Sources */, 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */, - 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */, - 6CAAF69229BCC71C00A1F48A /* (null) in Sources */, - 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/CodeEdit/Features/Activities/ActivityManager.swift b/CodeEdit/Features/Activities/ActivityManager.swift new file mode 100644 index 000000000..089999656 --- /dev/null +++ b/CodeEdit/Features/Activities/ActivityManager.swift @@ -0,0 +1,132 @@ +// +// ActivityManager.swift +// CodeEdit +// +// Created by Tommy Ludwig on 21.06.24. +// + +import Foundation +import Combine +import SwiftUI + +/// Manages activities for a workspace +@MainActor +final class ActivityManager: ObservableObject { + /// Currently displayed activities + @Published private(set) var activities: [CEActivity] = [] + + /// Debounce work item for batching updates + private var updateWorkItems: [String: DispatchWorkItem] = [:] + + /// Posts a new activity + /// - Parameters: + /// - priority: Whether to insert at start of list + /// - title: Activity title + /// - message: Optional detail message + /// - percentage: Optional progress percentage (0-1) + /// - isLoading: Whether activity shows loading indicator + /// - Returns: The created activity + @discardableResult + func post( + priority: Bool = false, + title: String, + message: String? = nil, + percentage: Double? = nil, + isLoading: Bool = false + ) -> CEActivity { + let activity = CEActivity( + id: UUID().uuidString, + title: title, + message: message, + percentage: percentage, + isLoading: isLoading + ) + + withAnimation(.easeInOut(duration: 0.3)) { + if priority { + activities.insert(activity, at: 0) + } else { + activities.append(activity) + } + } + + return activity + } + + /// Updates an existing activity with debouncing + /// - Parameters: + /// - id: ID of activity to update + /// - title: New title (optional) + /// - message: New message (optional) + /// - percentage: New progress percentage (optional) + /// - isLoading: New loading state (optional) + func update( + id: String, + title: String? = nil, + message: String? = nil, + percentage: Double? = nil, + isLoading: Bool? = nil + ) { + // Cancel any pending update for this specific activity + updateWorkItems[id]?.cancel() + + // Create new work item + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + + if let index = self.activities.firstIndex(where: { $0.id == id }) { + var activity = self.activities[index] + + if let title = title { + activity.title = title + } + if let message = message { + activity.message = message + } + if let percentage = percentage { + activity.percentage = percentage + } + if let isLoading = isLoading { + activity.isLoading = isLoading + } + + withAnimation(.easeInOut(duration: 0.15)) { + self.activities[index] = activity + } + } + + self.updateWorkItems.removeValue(forKey: id) + } + + // Store work item and schedule after delay + updateWorkItems[id] = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) + } + + /// Deletes an activity + /// - Parameter id: ID of activity to delete + func delete(id: String) { + // Clear any pending updates for this activity + updateWorkItems[id]?.cancel() + updateWorkItems.removeValue(forKey: id) + + withAnimation(.easeInOut(duration: 0.3)) { + activities.removeAll { $0.id == id } + } + } + + /// Deletes an activity after a delay + /// - Parameters: + /// - id: ID of activity to delete + /// - delay: Time to wait before deleting + func delete(id: String, delay: TimeInterval) { + Task { @MainActor in + try? await Task.sleep(for: .seconds(delay)) + delete(id: id) + } + } +} + +extension Notification.Name { + static let activity = Notification.Name("activity") +} diff --git a/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift b/CodeEdit/Features/Activities/Models/CEActivity.swift similarity index 57% rename from CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift rename to CodeEdit/Features/Activities/Models/CEActivity.swift index a92b618bd..a09e74f56 100644 --- a/CodeEdit/Features/ActivityViewer/Models/TaskNotificationModel.swift +++ b/CodeEdit/Features/Activities/Models/CEActivity.swift @@ -1,5 +1,5 @@ // -// TaskNotificationModel.swift +// CEActivity.swift // CodeEdit // // Created by Tommy Ludwig on 21.06.24. @@ -7,8 +7,8 @@ import Foundation -/// Represents a notifications or tasks, that are displayed in the activity viewer -struct TaskNotificationModel: Equatable { +/// Represents an activity, that is displayed in the activity viewer +struct CEActivity: Equatable { var id: String var title: String var message: String? diff --git a/CodeEdit/Features/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift b/CodeEdit/Features/Activities/ViewModifiers/DropdownMenuItemStyleModifier.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/DropdownMenuItemStyleModifier.swift rename to CodeEdit/Features/Activities/ViewModifiers/DropdownMenuItemStyleModifier.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/ActiveTaskView.swift b/CodeEdit/Features/Activities/Views/ActiveTaskView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/ActiveTaskView.swift rename to CodeEdit/Features/Activities/Views/ActiveTaskView.swift diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift b/CodeEdit/Features/Activities/Views/ActivitiesDetailView.swift similarity index 53% rename from CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift rename to CodeEdit/Features/Activities/Views/ActivitiesDetailView.swift index 4742fa817..da6c1fc1d 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationsDetailView.swift +++ b/CodeEdit/Features/Activities/Views/ActivitiesDetailView.swift @@ -1,5 +1,5 @@ // -// TaskNotificationsDetailView.swift +// ActivitiesDetailView.swift // CodeEdit // // Created by Tommy Ludwig on 21.06.24. @@ -7,22 +7,23 @@ import SwiftUI -struct TaskNotificationsDetailView: View { - @ObservedObject var taskNotificationHandler: TaskNotificationHandler - @State private var selectedTaskNotificationIndex: Int = 0 +struct ActivitiesDetailView: View { + @ObservedObject var activityManager: ActivityManager + @State private var selectedActivityIndex: Int = 0 + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 15) { - ForEach(taskNotificationHandler.notifications, id: \.id) { notification in + ForEach(activityManager.activities, id: \.id) { activity in HStack(alignment: .center, spacing: 8) { - CECircularProgressView(progress: notification.percentage) + CECircularProgressView(progress: activity.percentage) .frame(width: 16, height: 16) VStack(alignment: .leading) { - Text(notification.title) + Text(activity.title) .fixedSize(horizontal: false, vertical: true) .transition(.identity) - if let message = notification.message, !message.isEmpty { + if let message = activity.message, !message.isEmpty { Text(message) .font(.subheadline) .foregroundStyle(.secondary) @@ -35,14 +36,14 @@ struct TaskNotificationsDetailView: View { } .padding(15) .frame(minWidth: 320) - .onChange(of: taskNotificationHandler.notifications) { newValue in - if selectedTaskNotificationIndex >= newValue.count { - selectedTaskNotificationIndex = 0 + .onChange(of: activityManager.activities) { newValue in + if selectedActivityIndex >= newValue.count { + selectedActivityIndex = 0 } } } } #Preview { - TaskNotificationsDetailView(taskNotificationHandler: TaskNotificationHandler()) + ActivitiesDetailView(activityManager: ActivityManager()) } diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift b/CodeEdit/Features/Activities/Views/ActivityView.swift similarity index 61% rename from CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift rename to CodeEdit/Features/Activities/Views/ActivityView.swift index 1b9c1d0a3..3ae937488 100644 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationView.swift +++ b/CodeEdit/Features/Activities/Views/ActivityView.swift @@ -1,5 +1,5 @@ // -// TaskNotificationView.swift +// ActivityView.swift // CodeEdit // // Created by Tommy Ludwig on 21.06.24. @@ -7,36 +7,36 @@ import SwiftUI -struct TaskNotificationView: View { +struct ActivityView: View { @Environment(\.controlActiveState) private var activeState - @ObservedObject var taskNotificationHandler: TaskNotificationHandler + @ObservedObject var activityManager: ActivityManager @State private var isPresented: Bool = false - @State var notification: TaskNotificationModel? + @State var activity: CEActivity? var body: some View { ZStack { - if let notification { + if let activity { HStack { - Text(notification.title) + Text(activity.title) .font(.subheadline) .transition( .asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom)) .combined(with: .opacity) ) - .id("NotificationTitle" + notification.title) + .id("ActivityTitle" + activity.title) - if notification.isLoading { + if activity.isLoading { CECircularProgressView( - progress: notification.percentage, - currentTaskCount: taskNotificationHandler.notifications.count + progress: activity.percentage, + currentTaskCount: activityManager.activities.count ) .padding(.horizontal, -1) .frame(height: 16) } else { - if taskNotificationHandler.notifications.count > 1 { - Text("\(taskNotificationHandler.notifications.count)") + if activityManager.activities.count > 1 { + Text("\(activityManager.activities.count)") .font(.caption) .padding(5) .background( @@ -54,17 +54,17 @@ struct TaskNotificationView: View { .padding(-3) .padding(.trailing, 3) .popover(isPresented: $isPresented, arrowEdge: .bottom) { - TaskNotificationsDetailView(taskNotificationHandler: taskNotificationHandler) + ActivitiesDetailView(activityManager: activityManager) } .onTapGesture { self.isPresented.toggle() } } } - .animation(.easeInOut, value: notification) - .onChange(of: taskNotificationHandler.notifications) { newValue in + .animation(.easeInOut, value: activity) + .onChange(of: activityManager.activities) { newValue in withAnimation { - notification = newValue.first + activity = newValue.first } } } @@ -72,5 +72,5 @@ struct TaskNotificationView: View { } #Preview { - TaskNotificationView(taskNotificationHandler: TaskNotificationHandler()) + ActivityView(activityManager: ActivityManager()) } diff --git a/CodeEdit/Features/ActivityViewer/ActivityViewer.swift b/CodeEdit/Features/Activities/Views/ActivityViewer.swift similarity index 86% rename from CodeEdit/Features/ActivityViewer/ActivityViewer.swift rename to CodeEdit/Features/Activities/Views/ActivityViewer.swift index 5b3e41069..fd02b245e 100644 --- a/CodeEdit/Features/ActivityViewer/ActivityViewer.swift +++ b/CodeEdit/Features/Activities/Views/ActivityViewer.swift @@ -14,7 +14,7 @@ struct ActivityViewer: View { var workspaceFileManager: CEWorkspaceFileManager? - @ObservedObject var taskNotificationHandler: TaskNotificationHandler + @ObservedObject var activityManager: ActivityManager @ObservedObject var workspaceSettingsManager: CEWorkspaceSettings // TODO: try to get this from the envrionment @@ -23,12 +23,12 @@ struct ActivityViewer: View { init( workspaceFileManager: CEWorkspaceFileManager?, workspaceSettingsManager: CEWorkspaceSettings, - taskNotificationHandler: TaskNotificationHandler, + activityManager: ActivityManager, taskManager: TaskManager ) { self.workspaceFileManager = workspaceFileManager self.workspaceSettingsManager = workspaceSettingsManager - self.taskNotificationHandler = taskNotificationHandler + self.activityManager = activityManager self.taskManager = taskManager } var body: some View { @@ -42,7 +42,7 @@ struct ActivityViewer: View { Spacer(minLength: 0) - TaskNotificationView(taskNotificationHandler: taskNotificationHandler) + ActivityView(activityManager: activityManager) .fixedSize() } .fixedSize(horizontal: false, vertical: false) diff --git a/CodeEdit/Features/ActivityViewer/Notifications/CECircularProgressView.swift b/CodeEdit/Features/Activities/Views/CECircularProgressView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Notifications/CECircularProgressView.swift rename to CodeEdit/Features/Activities/Views/CECircularProgressView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/OptionMenuItemView.swift b/CodeEdit/Features/Activities/Views/OptionMenuItemView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/OptionMenuItemView.swift rename to CodeEdit/Features/Activities/Views/OptionMenuItemView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift b/CodeEdit/Features/Activities/Views/SchemeDropDownView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift rename to CodeEdit/Features/Activities/Views/SchemeDropDownView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift b/CodeEdit/Features/Activities/Views/TaskDropDownView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift rename to CodeEdit/Features/Activities/Views/TaskDropDownView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TaskView.swift b/CodeEdit/Features/Activities/Views/TaskView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/TaskView.swift rename to CodeEdit/Features/Activities/Views/TaskView.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TasksPopoverMenuItem.swift b/CodeEdit/Features/Activities/Views/TasksPopoverMenuItem.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/TasksPopoverMenuItem.swift rename to CodeEdit/Features/Activities/Views/TasksPopoverMenuItem.swift diff --git a/CodeEdit/Features/ActivityViewer/Tasks/WorkspaceMenuItemView.swift b/CodeEdit/Features/Activities/Views/WorkspaceMenuItemView.swift similarity index 100% rename from CodeEdit/Features/ActivityViewer/Tasks/WorkspaceMenuItemView.swift rename to CodeEdit/Features/Activities/Views/WorkspaceMenuItemView.swift diff --git a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift b/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift deleted file mode 100644 index ae0fdc645..000000000 --- a/CodeEdit/Features/ActivityViewer/Notifications/TaskNotificationHandler.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// 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/CodeEditUI/Views/WorkspacePanelView.swift b/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift index 4637d2785..f37cd2662 100644 --- a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift +++ b/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift @@ -40,6 +40,7 @@ struct WorkspacePanelView: CEContentUnavailableView("No Selection") } } + .clipped() .safeAreaInset(edge: .leading, spacing: 0) { if sidebarPosition == .side { HStack(spacing: 0) { diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift index 7551c0bed..0e4839200 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift @@ -41,7 +41,7 @@ final class CodeEditSplitViewController: NSSplitViewController { override func viewDidLoad() { super.viewDidLoad() - guard let windowRef else { + guard windowRef != nil else { // swiftlint:disable:next line_length assertionFailure("No WindowRef found, not initialized properly or the window was dereferenced and the controller was not.") return @@ -76,6 +76,7 @@ final class CodeEditSplitViewController: NSSplitViewController { .environmentObject(statusBarViewModel) .environmentObject(utilityAreaModel) .environmentObject(taskManager) + .environmentObject(workspace.activityManager) } } @@ -89,6 +90,7 @@ final class CodeEditSplitViewController: NSSplitViewController { InspectorAreaView(viewModel: InspectorAreaViewModel()) .environmentObject(workspace) .environmentObject(editorManager) + .environmentObject(workspace.activityManager) }) addSplitViewItem(inspector) diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift index e7d5f0282..644b3799c 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditWindowController+Toolbar.swift @@ -152,7 +152,7 @@ extension CodeEditWindowController { let toolbarItem = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier.activityViewer) toolbarItem.visibilityPriority = .user guard let workspaceSettingsManager = workspace?.workspaceSettingsManager, - let taskNotificationHandler = workspace?.taskNotificationHandler, + let activityManager = workspace?.activityManager, let taskManager = workspace?.taskManager else { return nil } @@ -160,7 +160,7 @@ extension CodeEditWindowController { rootView: ActivityViewer( workspaceFileManager: workspace?.workspaceFileManager, workspaceSettingsManager: workspaceSettingsManager, - taskNotificationHandler: taskNotificationHandler, + activityManager: activityManager, taskManager: taskManager ) ) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift index e84edceaf..99febae44 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift @@ -10,64 +10,83 @@ import Foundation 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() { + func addProjectToIndex() async { 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) + + // Create activity using new API + let activity = await MainActor.run { + workspace.activityManager.post( + title: "Indexing | Processing files", + message: "Creating an index to enable fast and accurate searches within your codebase.", + isLoading: true + ) + } + + let (progressStream, continuation) = AsyncStream.makeStream() + // Dispatch this now, we want to continue after starting to monitor + Task { await self.monitorProgressStream(progressStream, activityId: activity.id) } Task.detached { let filePaths = self.getFileURLs(at: url) - let asyncController = SearchIndexer.AsyncManager(index: indexer) var lastProgress: Double = 0 + // Batch our progress updates + var pendingProgress: Double? + for await (file, index) in AsyncFileIterator(fileURLs: filePaths) { _ = await asyncController.addText(files: [file], flushWhenComplete: false) let progress = Double(index) / Double(filePaths.count) - // Send only if difference is > 0.5%, to keep updates from sending too frequently - if progress - lastProgress > 0.005 || index == filePaths.count - 1 { + // Send only if difference is > 1% + if progress - lastProgress > 0.01 { lastProgress = progress - await MainActor.run { - self.indexStatus = .indexing(progress: progress) + pendingProgress = progress + + // Only update UI every 100ms + if index == filePaths.count - 1 || pendingProgress != nil { + continuation.yield(progress) + pendingProgress = nil } - let updateInfo: [String: Any] = [ - "id": uuidString, - "action": "update", - "percentage": progress - ] - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo) } } + asyncController.index.flush() await MainActor.run { self.indexStatus = .done + self.workspace.activityManager.update( + id: activity.id, + title: "Finished indexing", + isLoading: false + ) + self.workspace.activityManager.delete( + id: activity.id, + delay: 4.0 + ) } - 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) + /// Monitors a progress stream from ``addProjectToIndex()`` and updates ``indexStatus`` and the workspace's activity + /// manager accordingly. + /// + /// Without this, updates can come too fast for `Combine` to handle and can cause crashes. + /// + /// - Parameters: + /// - stream: The stream to monitor for progress updates, in %. + /// - activityId: The activity ID that's being monitored + @MainActor + private func monitorProgressStream(_ stream: AsyncStream, activityId: String) async { + for await progressUpdate in stream.debounce(for: .milliseconds(10)) { + self.indexStatus = .indexing(progress: progressUpdate) + self.workspace.activityManager.update( + id: activityId, + percentage: progressUpdate + ) } } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift index 261a4d224..046bc73dc 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift @@ -47,7 +47,9 @@ extension WorkspaceDocument { init(_ workspace: WorkspaceDocument) { self.workspace = workspace self.indexer = SearchIndexer.Memory.create() - addProjectToIndex() + Task { + await addProjectToIndex() + } } /// Represents the compare options to be used for find and replace. diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 1b96e4fca..896af7149 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -41,14 +41,23 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var taskManager: TaskManager? var workspaceSettingsManager: CEWorkspaceSettings? - var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + @Published var activityManager: ActivityManager = ActivityManager() @Published var notificationPanel = NotificationPanelViewModel() + private var cancellables = Set() override init() { super.init() + // Observe changes to activity manager + activityManager.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + // Observe changes to notification panel notificationPanel.objectWillChange .receive(on: DispatchQueue.main) @@ -156,7 +165,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { if let workspaceSettingsManager { self.taskManager = TaskManager( workspaceSettings: workspaceSettingsManager.settings, - workspaceURL: url + workspaceURL: url, + workspace: self ) } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentActivitiesView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentActivitiesView.swift new file mode 100644 index 000000000..ed5241c53 --- /dev/null +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentActivitiesView.swift @@ -0,0 +1,128 @@ +// +// InternalDevelopmentActivitiesView.swift +// CodeEdit +// +// Created by Austin Condiff on 2/20/25. +// + +import SwiftUI + +struct InternalDevelopmentActivitiesView: View { + @EnvironmentObject var activityManager: ActivityManager + + @State private var activityTitle: String = "Test Activity" + @State private var activityMessage: String = "This is a test activity." + @State private var activityProgress: Double = 0.0 + @State private var isLoading: Bool = false + @State private var isPriority: Bool = false + @State private var autoDelete: Bool = false + @State private var deleteDelay: Double = 3.0 + + // New state for progress timer + @State private var isProgressTimerRunning: Bool = false + @State private var progressActivity: CEActivity? + + var body: some View { + Section("Activities") { + Toggle("Priority", isOn: $isPriority) + Toggle("Loading", isOn: $isLoading) + + TextField("Title", text: $activityTitle) + TextField("Message", text: $activityMessage, axis: .vertical) + .lineLimit(1...5) + + if !isLoading { + HStack { + Text("Progress") + Slider(value: $activityProgress, in: 0...1) + Text("\(Int(activityProgress * 100))%") + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + } + + Toggle("Auto Delete", isOn: $autoDelete) + + if autoDelete { + HStack { + Text("Delete After") + Slider(value: $deleteDelay, in: 1...10) + Text("\(Int(deleteDelay))s") + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + } + } + + Button("Add Activity") { + let activity = activityManager.post( + priority: isPriority, + title: activityTitle, + message: activityMessage, + percentage: isLoading ? nil : activityProgress, + isLoading: isLoading + ) + + if autoDelete { + activityManager.delete(id: activity.id, delay: deleteDelay) + } + } + + if !activityManager.activities.isEmpty { + Button("Clear All Activities") { + for activity in activityManager.activities { + activityManager.delete(id: activity.id) + } + } + } + + Section("Progress Timer Test") { + Button(isProgressTimerRunning ? "Stop Progress Timer" : "Start Progress Timer") { + if isProgressTimerRunning { + isProgressTimerRunning = false + if let activity = progressActivity { + activityManager.delete(id: activity.id) + } + progressActivity = nil + } else { + isProgressTimerRunning = true + progressActivity = activityManager.post( + priority: isPriority, + title: "Progress Timer", + message: "Updating every 50ms", + percentage: 0.0 + ) + + // Start timer to update progress + Task { @MainActor in + var progress = 0.0 + while isProgressTimerRunning && progress < 1.0 { + // Update in 5% increments + progress = min(1.0, progress + 0.05) + if let activity = progressActivity { + activityManager.update( + id: activity.id, + percentage: progress + ) + } + // Wait longer between updates + try? await Task.sleep(for: .milliseconds(100)) + } + + // Cleanup when done + if let activity = progressActivity { + activityManager.update( + id: activity.id, + title: "Progress Timer Complete", + percentage: 1.0 + ) + activityManager.delete(id: activity.id, delay: 2.0) + } + isProgressTimerRunning = false + progressActivity = nil + } + } + } + } + } + } +} diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift index 0906bbcbf..9d061cc1e 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentInspectorView.swift @@ -2,14 +2,17 @@ // InternalDevelopmentInspectorView.swift // CodeEdit // -// Created by Austin Condiff on 2/19/24. +// Created by Austin Condiff on 2/19/25. // import SwiftUI struct InternalDevelopmentInspectorView: View { + @EnvironmentObject var activityManager: ActivityManager + var body: some View { Form { + InternalDevelopmentActivitiesView() InternalDevelopmentNotificationsView() } } diff --git a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift index 533488099..1ea73a278 100644 --- a/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift +++ b/CodeEdit/Features/InspectorArea/InternalDevelopmentInspector/InternalDevelopmentNotificationsView.swift @@ -2,7 +2,7 @@ // InternalDevelopmentNotificationsView.swift // CodeEdit // -// Created by Austin Condiff on 2/19/24. +// Created by Austin Condiff on 2/19/25. // import SwiftUI diff --git a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift index 967023db1..18febee98 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+Delegate.swift @@ -43,7 +43,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { completionHandler([.banner, .sound]) } - func setupNotificationDelegate() { + public func setupNotificationDelegate() { UNUserNotificationCenter.current().delegate = self // Create action button diff --git a/CodeEdit/Features/Notifications/NotificationManager+System.swift b/CodeEdit/Features/Notifications/NotificationManager+System.swift index 24522d7c4..ab2c4773a 100644 --- a/CodeEdit/Features/Notifications/NotificationManager+System.swift +++ b/CodeEdit/Features/Notifications/NotificationManager+System.swift @@ -10,7 +10,7 @@ import UserNotifications extension NotificationManager { /// Shows a system notification when app is in background - func showSystemNotification(_ notification: CENotification) { + public func showSystemNotification(_ notification: CENotification) { let content = UNMutableNotificationContent() content.title = notification.title content.body = notification.description @@ -29,14 +29,14 @@ extension NotificationManager { } /// Removes a system notification - func removeSystemNotification(_ notification: CENotification) { + public func removeSystemNotification(_ notification: CENotification) { UNUserNotificationCenter.current().removeDeliveredNotifications( withIdentifiers: [notification.id.uuidString] ) } /// Handles response from system notification - func handleSystemNotificationResponse(id: String) { + public func handleSystemNotificationResponse(id: String) { if let uuid = UUID(uuidString: id), let notification = notifications.first(where: { $0.id == uuid }) { notification.action() diff --git a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift index 11a90696a..c74e763d4 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationBannerView.swift @@ -12,7 +12,6 @@ struct NotificationBannerView: View { private var colorScheme @EnvironmentObject private var workspace: WorkspaceDocument - @ObservedObject private var notificationManager = NotificationManager.shared let notification: CENotification let onDismiss: () -> Void diff --git a/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift index c2c764eeb..fd8de522a 100644 --- a/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift +++ b/CodeEdit/Features/Notifications/Views/NotificationPanelView.swift @@ -12,7 +12,6 @@ struct NotificationPanelView: View { @Environment(\.controlActiveState) private var controlActiveState - @ObservedObject private var notificationManager = NotificationManager.shared @FocusState private var isFocused: Bool // ID for the top anchor diff --git a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift index 3b6d94cdb..45d296cfc 100644 --- a/CodeEdit/Features/Tasks/Models/CEActiveTask.swift +++ b/CodeEdit/Features/Tasks/Models/CEActiveTask.swift @@ -19,13 +19,20 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { /// The name of the associated task. @ObservedObject var task: CETask + /// Reference to the workspace that owns this task + weak var workspace: WorkspaceDocument? + + /// The activity associated with this task + private var activity: CEActivity? + var process: Process? var outputPipe: Pipe? private var cancellables = Set() - init(task: CETask) { + init(task: CETask, workspace: WorkspaceDocument?) { self.task = task + self.workspace = workspace self.process = Process() self.outputPipe = Pipe() @@ -47,7 +54,7 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { guard let process, let outputPipe else { return } await updateTaskStatus(to: .running) - createStatusTaskNotification() + createStatusActivity() process.terminationHandler = { [weak self] capturedProcess in if let self { @@ -84,7 +91,7 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { if terminationStatus == 0 { await updateOutput("\nFinished running \(task.name).\n\n") await updateTaskStatus(to: .finished) - updateTaskNotification( + updateActivity( title: "Finished Running \(task.name)", message: "", isLoading: false @@ -92,7 +99,7 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { } else if terminationStatus == 15 { await updateOutput("\n\(task.name) cancelled.\n\n") await updateTaskStatus(to: .notRunning) - updateTaskNotification( + updateActivity( title: "\(task.name) cancelled", message: "", isLoading: false @@ -100,7 +107,7 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { } else { await updateOutput("\nFailed to run \(task.name).\n\n") await updateTaskStatus(to: .failed) - updateTaskNotification( + updateActivity( title: "Failed Running \(task.name)", message: "", isLoading: false @@ -108,7 +115,7 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { } outputPipe?.fileHandleForReading.readabilityHandler = nil - deleteStatusTaskNotification() + deleteStatusActivity() } func renew() { @@ -144,44 +151,33 @@ class CEActiveTask: ObservableObject, Identifiable, Hashable { output = "" } - private func createStatusTaskNotification() { - let userInfo: [String: Any] = [ - "id": self.task.id.uuidString, - "action": "createWithPriority", - "title": "Running \(self.task.name)", - "message": "Running your task: \(self.task.name).", - "isLoading": true - ] - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) + private func createStatusActivity() { + activity = workspace?.activityManager.post( + priority: true, + title: "Running \(self.task.name)", + message: "Running your task: \(self.task.name).", + isLoading: true + ) } - private func deleteStatusTaskNotification() { - let deleteInfo: [String: Any] = [ - "id": "\(task.id.uuidString)", - "action": "deleteWithDelay", - "delay": 3.0 - ] - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo) + private func deleteStatusActivity() { + if let activityId = activity?.id { + workspace?.activityManager.delete( + id: activityId, + delay: 3.0 + ) + } } - private func updateTaskNotification(title: String? = nil, message: String? = nil, isLoading: Bool? = nil) { - var userInfo: [String: Any] = [ - "id": task.id.uuidString, - "action": "update" - ] - if let title { - userInfo["title"] = title - } - if let message { - userInfo["message"] = message - } - if let isLoading { - userInfo["isLoading"] = isLoading + private func updateActivity(title: String? = nil, message: String? = nil, isLoading: Bool? = nil) { + if let activityId = activity?.id { + workspace?.activityManager.update( + id: activityId, + title: title, + message: message, + isLoading: isLoading + ) } - - NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: userInfo) } private func updateTaskStatus(to taskStatus: CETaskStatus) async { diff --git a/CodeEdit/Features/CEWorkspaceSettings/Models/CETask.swift b/CodeEdit/Features/Tasks/Models/CETask.swift similarity index 100% rename from CodeEdit/Features/CEWorkspaceSettings/Models/CETask.swift rename to CodeEdit/Features/Tasks/Models/CETask.swift diff --git a/CodeEdit/Features/Tasks/TaskManager.swift b/CodeEdit/Features/Tasks/TaskManager.swift index aeb96e2de..ec78262e8 100644 --- a/CodeEdit/Features/Tasks/TaskManager.swift +++ b/CodeEdit/Features/Tasks/TaskManager.swift @@ -18,10 +18,16 @@ class TaskManager: ObservableObject { private var workspaceURL: URL? private var settingsListener: AnyCancellable? + private weak var workspace: WorkspaceDocument? - init(workspaceSettings: CEWorkspaceSettingsData, workspaceURL: URL? = nil) { + init( + workspaceSettings: CEWorkspaceSettingsData, + workspaceURL: URL? = nil, + workspace: WorkspaceDocument? = nil + ) { self.workspaceURL = workspaceURL self.workspaceSettings = workspaceSettings + self.workspace = workspace settingsListener = workspaceSettings.$tasks .receive(on: DispatchQueue.main) @@ -79,7 +85,7 @@ class TaskManager: ObservableObject { } activeTask.run(workspaceURL: workspaceURL) } else { - let runningTask = CEActiveTask(task: task) + let runningTask = CEActiveTask(task: task, workspace: workspace) runningTask.run(workspaceURL: workspaceURL) await MainActor.run { activeTasks[task.id] = runningTask diff --git a/CodeEditTests/Features/Activities/ActivityManagerTests.swift b/CodeEditTests/Features/Activities/ActivityManagerTests.swift new file mode 100644 index 000000000..2a32678c5 --- /dev/null +++ b/CodeEditTests/Features/Activities/ActivityManagerTests.swift @@ -0,0 +1,93 @@ +// +// ActivityManagerTests.swift +// CodeEditTests +// +// Created by Tommy Ludwig on 21.06.24. +// + +import XCTest +@testable import CodeEdit + +final class ActivityManagerTests: XCTestCase { + var activityManager: ActivityManager! + + override func setUp() async throws { + try await super.setUp() + // Initialize on main actor since ActivityManager is main actor-isolated + await MainActor.run { + activityManager = ActivityManager() + } + } + + override func tearDown() async throws { + await MainActor.run { + activityManager = nil + } + try await super.tearDown() + } + + func testCreateTask() async { + await MainActor.run { + let activity = activityManager.post(title: "Task Title") + XCTAssertEqual(activityManager.activities.first?.id, activity.id) + XCTAssertEqual(activityManager.activities.first?.title, "Task Title") + } + } + + func testCreateTaskWithPriority() async { + await MainActor.run { + let activity1 = activityManager.post(title: "Task Title") + let activity2 = activityManager.post( + priority: true, + title: "Priority Task Title" + ) + + XCTAssertEqual(activityManager.activities.first?.id, activity2.id) + XCTAssertEqual(activityManager.activities.first?.title, "Priority Task Title") + XCTAssertEqual(activityManager.activities.last?.id, activity1.id) + } + } + + func testUpdateTask() async { + await MainActor.run { + let activity = activityManager.post(title: "Task Title") + + activityManager.update( + id: activity.id, + title: "Updated Task Title" + ) + + XCTAssertEqual(activityManager.activities.first?.title, "Updated Task Title") + } + } + + func testDeleteTask() async { + await MainActor.run { + let activity = activityManager.post(title: "Task Title") + activityManager.delete(id: activity.id) + + XCTAssertTrue(activityManager.activities.isEmpty) + } + } + + func testDeleteTaskWithDelay() async throws { + let expectation = XCTestExpectation() + + await MainActor.run { + let activity = activityManager.post(title: "Task Title") + activityManager.delete(id: activity.id, delay: 0.2) + + XCTAssertFalse(activityManager.activities.isEmpty) + } + + // Wait for deletion + try await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + + await MainActor.run { + XCTAssertTrue(self.activityManager.activities.isEmpty) + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 1) + } +} diff --git a/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift b/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift deleted file mode 100644 index 0d02c69f9..000000000 --- a/CodeEditTests/Features/ActivityViewer/TaskNotificationHandlerTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// 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) - } -} diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift index 72fd990db..b03099365 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift @@ -99,7 +99,9 @@ final class FindAndReplaceTests: XCTestCase { // IMPORTANT: // This is only a temporary solution, in the feature a file watcher would track the file update // and trigger a index update. - searchState.addProjectToIndex() + Task { + await searchState.addProjectToIndex() + } let startTime = Date() while searchState.indexStatus != .done { try? await Task.sleep(nanoseconds: 100_000_000) diff --git a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift index 4ecc58fac..e13446af1 100644 --- a/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift +++ b/CodeEditTests/Features/Tasks/CEActiveTaskTests.swift @@ -11,21 +11,28 @@ import XCTest final class CEActiveTaskTests: XCTestCase { var task: CETask! var activeTask: CEActiveTask! + var mockWorkspace: WorkspaceDocument! override func setUpWithError() throws { try super.setUpWithError() + mockWorkspace = try WorkspaceDocument( + contentsOf: URL(fileURLWithPath: NSTemporaryDirectory()), + ofType: "public.folder" + ) + task = CETask( name: "Test Task", command: "echo $STATE", environmentVariables: [CETask.EnvironmentVariable(key: "STATE", value: "Testing")] ) - activeTask = CEActiveTask(task: task) + activeTask = CEActiveTask(task: task, workspace: mockWorkspace) } override func tearDownWithError() throws { task = nil activeTask = nil + mockWorkspace = nil try super.tearDownWithError() }