Skip to content

Refactored Activities for clarity and a consistent API #1993

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 32 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2f63de4
Added global notifications system
austincondiff Feb 11, 2025
898a87c
Refactored FeatureIcon and notifications to support custom images
austincondiff Feb 11, 2025
418e540
Fixed SwiftLint errors
austincondiff Feb 11, 2025
14fc34f
Fixed SwiftLint errors
austincondiff Feb 12, 2025
2e73c75
Deleted unused file
austincondiff Feb 12, 2025
a0e7357
Added sticky notifications. Allowed multiple notifications to overlay…
austincondiff Feb 12, 2025
5ba7a66
Streamlined UI getting rid of the notifications popover in favor of t…
austincondiff Feb 13, 2025
e7216ca
Added a close button click state, allows clicking under scroll view.
austincondiff Feb 14, 2025
4bc9151
Remove test notification
austincondiff Feb 15, 2025
d636277
Improve notification handling and workspace panel behavior
austincondiff Feb 18, 2025
9df84a1
Fixed SwiftLint and PR issues
austincondiff Feb 18, 2025
951e899
Fixed animation glitch when taking action on a notification while not…
austincondiff Feb 19, 2025
4518e4c
Changed variable name isManuallyShown to isPresented, and notificatio…
austincondiff Feb 19, 2025
46adb4c
Refactored CENotification to use delegated inits.
austincondiff Feb 19, 2025
8abb2b5
Moved CloseButtonStyle into CodeEditUI and renamed it OverlayButtonStyle
austincondiff Feb 20, 2025
6bdde36
Using cancelables so workspace cleanup is more concise
austincondiff Feb 20, 2025
42f802e
Refactored Activities (previously TaskNotifications) to have a clear …
austincondiff Feb 20, 2025
596847c
Renamed a few files for consistency
austincondiff Feb 20, 2025
87ebaa7
Added Activities section to the Internal Developer Inspector for test…
austincondiff Feb 20, 2025
4b262f8
Fixed SwiftLint errors and made a few adjustments
austincondiff Feb 21, 2025
97d7f07
Added update and delete functions to notification manager.
austincondiff Feb 21, 2025
694d868
Fixed some tests
austincondiff Feb 21, 2025
e602eab
Put back window observer
austincondiff Feb 22, 2025
09e52d7
Clip the inspector so when scrolled, contents can't be seen under too…
austincondiff Feb 26, 2025
43ca864
Performance improvements
austincondiff Feb 27, 2025
ca6a3fc
Made fixes suggested in PR.
austincondiff Mar 4, 2025
c4f7479
Rebased from latest main
austincondiff Mar 4, 2025
6f2e611
Removed files inadvertedly added durring rebase
austincondiff Mar 4, 2025
17b940f
Rebased from main
austincondiff Mar 5, 2025
46c94d1
SwiftLint fix
austincondiff Mar 5, 2025
299ad40
Cleanup
austincondiff Mar 5, 2025
ce4f72c
Cleanup
austincondiff Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
6C67413D2C44A28C00AABDF5 /* ProjectNavigatorViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+DataSource.swift"; sourceTree = "<group>"; };
6C67413F2C44A2A200AABDF5 /* ProjectNavigatorViewController+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectNavigatorViewController+Delegate.swift"; sourceTree = "<group>"; };
6C9619262C3F285C009733CE /* CodeEditTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CodeEditTestPlan.xctestplan; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -215,15 +209,6 @@
name = Frameworks;
sourceTree = "<group>";
};
6C01F25D2C4820B600AA951B /* Recovered References */ = {
isa = PBXGroup;
children = (
6C67413D2C44A28C00AABDF5 /* ProjectNavigatorViewController+DataSource.swift */,
6C67413F2C44A2A200AABDF5 /* ProjectNavigatorViewController+Delegate.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
B658FB2327DA9E0F00EA4DBD = {
isa = PBXGroup;
children = (
Expand All @@ -239,7 +224,6 @@
283BDCBC2972EEBD002AFF81 /* Package.resolved */,
B658FB2D27DA9E0F00EA4DBD /* Products */,
5C403B8D27E20F8000788241 /* Frameworks */,
6C01F25D2C4820B600AA951B /* Recovered References */,
);
indentWidth = 4;
sourceTree = "<group>";
Expand Down Expand Up @@ -380,7 +364,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1540;
LastUpgradeCheck = 1620;
TargetAttributes = {
2BE487EB28245162003F3F64 = {
CreatedOnToolsVersion = 13.3.1;
Expand Down Expand Up @@ -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;
};
Expand Down
132 changes: 132 additions & 0 deletions CodeEdit/Features/Activities/ActivityManager.swift
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
//
// TaskNotificationModel.swift
// CEActivity.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 {
/// Represents an activity, that is displayed in the activity viewer
struct CEActivity: Equatable {
var id: String
var title: String
var message: String?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
//
// TaskNotificationsDetailView.swift
// ActivitiesDetailView.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
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)
Expand All @@ -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())
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
//
// TaskNotificationView.swift
// ActivityView.swift
// CodeEdit
//
// Created by Tommy Ludwig on 21.06.24.
//

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(
Expand All @@ -54,23 +54,23 @@ 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
}
}
}

}

#Preview {
TaskNotificationView(taskNotificationHandler: TaskNotificationHandler())
ActivityView(activityManager: ActivityManager())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -42,7 +42,7 @@ struct ActivityViewer: View {

Spacer(minLength: 0)

TaskNotificationView(taskNotificationHandler: taskNotificationHandler)
ActivityView(activityManager: activityManager)
.fixedSize()
}
.fixedSize(horizontal: false, vertical: false)
Expand Down
Loading
Loading