Commit 8fc97b98ae
Unsigned
Layout: unified · split
Hutch/App/AppStorageKeys.swift +2
| @@ -5,12 +5,14 @@ enum AppStorageKeys { | |||
| 5 | static let activeAccountID = "activeAccountID" | 5 | static let activeAccountID = "activeAccountID" |
| 6 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" | 6 | static let wrapRepositoryFileLines = "wrapRepositoryFileLines" |
| 7 | static let lookupHistory = "lookupHistory" | 7 | static let lookupHistory = "lookupHistory" |
| 8 | static let recentActivity = "recentActivity" | ||
| 8 | static let scopedSearchHistory = "scopedSearchHistory" | 9 | static let scopedSearchHistory = "scopedSearchHistory" |
| 9 | static let hutchStatsBaseURL = "hutchStatsBaseURL" | 10 | static let hutchStatsBaseURL = "hutchStatsBaseURL" |
| 10 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" | 11 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" |
| 11 | static let systemStatusIncidentCache = "systemStatusIncidentCache" | 12 | static let systemStatusIncidentCache = "systemStatusIncidentCache" |
| 12 | static let homeProjectsExpanded = "homeProjectsExpanded" | 13 | static let homeProjectsExpanded = "homeProjectsExpanded" |
| 13 | static let pinnedHomeProjects = "pinnedHomeProjects" | 14 | static let pinnedHomeProjects = "pinnedHomeProjects" |
| 15 | static let pinnedHomeItems = "pinnedHomeItems" | ||
| 14 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" | 16 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" |
| 15 | static let homeBuildsExpanded = "homeBuildsExpanded" | 17 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 16 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" | 18 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" |
Hutch/App/DeepLink.swift +4
| @@ -3,6 +3,7 @@ import Foundation | |||
| 3 | /// Represents a parsed `hutch://` deep link. | 3 | /// Represents a parsed `hutch://` deep link. |
| 4 | enum DeepLink: Equatable { | 4 | enum DeepLink: Equatable { |
| 5 | case home | 5 | case home |
| 6 | case work | ||
| 6 | /// hutch://git/<owner>/<repo> | 7 | /// hutch://git/<owner>/<repo> |
| 7 | case repository(owner: String, repo: String) | 8 | case repository(owner: String, repo: String) |
| 8 | /// hutch://todo/<owner>/<tracker>/<ticketId> | 9 | /// hutch://todo/<owner>/<tracker>/<ticketId> |
| @@ -33,6 +34,9 @@ enum DeepLink: Equatable { | |||
| 33 | case "home", nil: | 34 | case "home", nil: |
| 34 | self = .home | 35 | self = .home |
| 35 | 36 | ||
| 37 | case "work", "inbox": | ||
| 38 | self = .work | ||
| 39 | |||
| 36 | case "git" where components.count >= 3: | 40 | case "git" where components.count >= 3: |
| 37 | let owner = components[1] | 41 | let owner = components[1] |
| 38 | let repo = components[2] | 42 | let repo = components[2] |
Hutch/App/HutchApp.swift +1 −1
| @@ -26,7 +26,7 @@ struct HutchApp: App { | |||
| 26 | let link: DeepLink | 26 | let link: DeepLink |
| 27 | switch destination { | 27 | switch destination { |
| 28 | case .home: link = .home | 28 | case .home: link = .home |
| 29 | case .inbox: link = .home | 29 | case .work: link = .work |
| 30 | case .builds: link = .buildsTab | 30 | case .builds: link = .buildsTab |
| 31 | case .repositories: link = .repositoriesTab | 31 | case .repositories: link = .repositoriesTab |
| 32 | case .trackers: link = .trackersTab | 32 | case .trackers: link = .trackersTab |
Hutch/App/HutchIntents.swift +2 −2
| @@ -5,7 +5,7 @@ import Foundation | |||
| 5 | 5 | ||
| 6 | enum HutchDestination: String, AppEnum { | 6 | enum HutchDestination: String, AppEnum { |
| 7 | case home | 7 | case home |
| 8 | case inbox | 8 | case work |
| 9 | case builds | 9 | case builds |
| 10 | case repositories | 10 | case repositories |
| 11 | case trackers | 11 | case trackers |
| @@ -16,7 +16,7 @@ enum HutchDestination: String, AppEnum { | |||
| 16 | 16 | ||
| 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ | 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ |
| 18 | .home: "Home", | 18 | .home: "Home", |
| 19 | .inbox: "Inbox", | 19 | .work: "Work", |
| 20 | .builds: "Builds", | 20 | .builds: "Builds", |
| 21 | .repositories: "Repositories", | 21 | .repositories: "Repositories", |
| 22 | .trackers: "Trackers", | 22 | .trackers: "Trackers", |
Hutch/App/RootView.swift +14
| @@ -69,6 +69,12 @@ struct RootView: View { | |||
| 69 | return TabView(selection: $appState.selectedTab) { | 69 | return TabView(selection: $appState.selectedTab) { |
| 70 | NavigationStack(path: $homePath) { | 70 | NavigationStack(path: $homePath) { |
| 71 | HomeView() | 71 | HomeView() |
| 72 | .navigationDestination(for: HomeRoute.self) { route in | ||
| 73 | switch route { | ||
| 74 | case .work: | ||
| 75 | WorkView() | ||
| 76 | } | ||
| 77 | } | ||
| 72 | } | 78 | } |
| 73 | .tag(AppState.Tab.home) | 79 | .tag(AppState.Tab.home) |
| 74 | .tabItem { | 80 | .tabItem { |
| @@ -196,6 +202,14 @@ struct RootView: View { | |||
| 196 | case .ticket(let owner, let tracker, let ticketId): | 202 | case .ticket(let owner, let tracker, let ticketId): |
| 197 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) | 203 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) |
| 198 | 204 | ||
| 205 | case .work: | ||
| 206 | homePath = NavigationPath() | ||
| 207 | appState.selectedTab = .home | ||
| 208 | Task { | ||
| 209 | await settleNavigationTransition() | ||
| 210 | homePath.append(HomeRoute.work) | ||
| 211 | } | ||
| 212 | |||
| 199 | case .buildsTab: | 213 | case .buildsTab: |
| 200 | buildsPath = NavigationPath() | 214 | buildsPath = NavigationPath() |
| 201 | appState.selectedTab = .builds | 215 | appState.selectedTab = .builds |
Hutch/Views/Builds/BuildDetailView.swift +17
| @@ -331,6 +331,13 @@ struct BuildDetailView: View { | |||
| 331 | } | 331 | } |
| 332 | } | 332 | } |
| 333 | } | 333 | } |
| 334 | .task(id: job.id) { | ||
| 335 | RecentActivityStore.recordBuild( | ||
| 336 | jobId: job.id, | ||
| 337 | title: recentActivityTitle(for: job), | ||
| 338 | defaults: appState.accountDefaults | ||
| 339 | ) | ||
| 340 | } | ||
| 334 | .refreshable { | 341 | .refreshable { |
| 335 | await reloadDetail(viewModel) | 342 | await reloadDetail(viewModel) |
| 336 | } | 343 | } |
| @@ -367,6 +374,16 @@ struct BuildDetailView: View { | |||
| 367 | await viewModel.loadJob() | 374 | await viewModel.loadJob() |
| 368 | } | 375 | } |
| 369 | } | 376 | } |
| 377 | |||
| 378 | private func recentActivityTitle(for job: JobDetail) -> String { | ||
| 379 | if let note = job.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { | ||
| 380 | return note | ||
| 381 | } | ||
| 382 | if !job.tags.isEmpty { | ||
| 383 | return job.tags.joined(separator: ", ") | ||
| 384 | } | ||
| 385 | return "Job #\(job.id)" | ||
| 386 | } | ||
| 370 | } | 387 | } |
| 371 | 388 | ||
| 372 | private struct BuildArtifactRow: View { | 389 | private struct BuildArtifactRow: View { |
Hutch/Views/Home/HomePinStore.swift added +207
| @@ -0,0 +1,207 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum HomePinKind: String, Codable, Sendable { | ||
| 4 | case project | ||
| 5 | case repository | ||
| 6 | case tracker | ||
| 7 | case mailingList | ||
| 8 | case user | ||
| 9 | } | ||
| 10 | |||
| 11 | struct HomePinRecord: Codable, Hashable, Identifiable, Sendable { | ||
| 12 | let kind: HomePinKind | ||
| 13 | let value: String | ||
| 14 | let title: String | ||
| 15 | let subtitle: String | ||
| 16 | let ownerUsername: String? | ||
| 17 | let service: SRHTService? | ||
| 18 | |||
| 19 | var id: String { | ||
| 20 | switch kind { | ||
| 21 | case .project: | ||
| 22 | return "\(kind.rawValue):\(value)" | ||
| 23 | case .repository: | ||
| 24 | return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)" | ||
| 25 | case .tracker, .mailingList: | ||
| 26 | return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)" | ||
| 27 | case .user: | ||
| 28 | return "\(kind.rawValue):\(ownerUsername ?? value)" | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | static func project(_ project: Project) -> HomePinRecord { | ||
| 33 | HomePinRecord( | ||
| 34 | kind: .project, | ||
| 35 | value: project.id, | ||
| 36 | title: project.displayName, | ||
| 37 | subtitle: "Project", | ||
| 38 | ownerUsername: nil, | ||
| 39 | service: nil | ||
| 40 | ) | ||
| 41 | } | ||
| 42 | |||
| 43 | static func repository(_ repository: RepositorySummary) -> HomePinRecord { | ||
| 44 | HomePinRecord( | ||
| 45 | kind: .repository, | ||
| 46 | value: repository.name, | ||
| 47 | title: repository.name, | ||
| 48 | subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo", | ||
| 49 | ownerUsername: repository.owner.canonicalName.srhtUsername, | ||
| 50 | service: repository.service | ||
| 51 | ) | ||
| 52 | } | ||
| 53 | |||
| 54 | static func tracker(_ tracker: TrackerSummary) -> HomePinRecord { | ||
| 55 | HomePinRecord( | ||
| 56 | kind: .tracker, | ||
| 57 | value: tracker.name, | ||
| 58 | title: tracker.name, | ||
| 59 | subtitle: "Tracker", | ||
| 60 | ownerUsername: tracker.owner.canonicalName.srhtUsername, | ||
| 61 | service: nil | ||
| 62 | ) | ||
| 63 | } | ||
| 64 | |||
| 65 | static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord { | ||
| 66 | HomePinRecord( | ||
| 67 | kind: .mailingList, | ||
| 68 | value: mailingList.rid, | ||
| 69 | title: mailingList.name, | ||
| 70 | subtitle: "Mailing List", | ||
| 71 | ownerUsername: mailingList.owner.canonicalName.srhtUsername, | ||
| 72 | service: nil | ||
| 73 | ) | ||
| 74 | } | ||
| 75 | |||
| 76 | static func user(_ user: User) -> HomePinRecord { | ||
| 77 | HomePinRecord( | ||
| 78 | kind: .user, | ||
| 79 | value: user.username, | ||
| 80 | title: user.canonicalName, | ||
| 81 | subtitle: "User", | ||
| 82 | ownerUsername: user.username, | ||
| 83 | service: nil | ||
| 84 | ) | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | enum HomePinStore { | ||
| 89 | static func loadPins( | ||
| 90 | for userKey: String, | ||
| 91 | defaults: UserDefaults = .standard | ||
| 92 | ) -> [HomePinRecord] { | ||
| 93 | let normalizedUserKey = normalizedUserKey(userKey) | ||
| 94 | guard !normalizedUserKey.isEmpty else { return [] } | ||
| 95 | |||
| 96 | let storedPins = loadAll(defaults: defaults) | ||
| 97 | if let storedPinsForUser = storedPins[normalizedUserKey] { | ||
| 98 | return normalizedPins(storedPinsForUser) | ||
| 99 | } | ||
| 100 | |||
| 101 | let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults) | ||
| 102 | guard !legacyProjectIDs.isEmpty else { return [] } | ||
| 103 | |||
| 104 | let migratedPins = legacyProjectIDs.map { | ||
| 105 | HomePinRecord( | ||
| 106 | kind: .project, | ||
| 107 | value: $0, | ||
| 108 | title: "Pinned Project", | ||
| 109 | subtitle: "Project", | ||
| 110 | ownerUsername: nil, | ||
| 111 | service: nil | ||
| 112 | ) | ||
| 113 | } | ||
| 114 | savePins(migratedPins, for: normalizedUserKey, defaults: defaults) | ||
| 115 | return migratedPins | ||
| 116 | } | ||
| 117 | |||
| 118 | static func isPinned( | ||
| 119 | _ pin: HomePinRecord, | ||
| 120 | for userKey: String, | ||
| 121 | defaults: UserDefaults = .standard | ||
| 122 | ) -> Bool { | ||
| 123 | loadPins(for: userKey, defaults: defaults).contains(pin) | ||
| 124 | } | ||
| 125 | |||
| 126 | static func togglePin( | ||
| 127 | _ pin: HomePinRecord, | ||
| 128 | for userKey: String, | ||
| 129 | defaults: UserDefaults = .standard | ||
| 130 | ) { | ||
| 131 | let normalizedUserKey = normalizedUserKey(userKey) | ||
| 132 | guard !normalizedUserKey.isEmpty else { return } | ||
| 133 | |||
| 134 | var pinsByUser = loadAll(defaults: defaults) | ||
| 135 | var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults)) | ||
| 136 | |||
| 137 | if let index = pins.firstIndex(of: pin) { | ||
| 138 | pins.remove(at: index) | ||
| 139 | } else { | ||
| 140 | pins.append(pin) | ||
| 141 | } | ||
| 142 | |||
| 143 | pinsByUser[normalizedUserKey] = pins | ||
| 144 | saveAll(pinsByUser, defaults: defaults) | ||
| 145 | } | ||
| 146 | |||
| 147 | static func pinnedProjectIDs( | ||
| 148 | for userKey: String, | ||
| 149 | defaults: UserDefaults = .standard | ||
| 150 | ) -> [String] { | ||
| 151 | loadPins(for: userKey, defaults: defaults) | ||
| 152 | .filter { $0.kind == .project } | ||
| 153 | .map(\.value) | ||
| 154 | } | ||
| 155 | |||
| 156 | private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] { | ||
| 157 | guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else { | ||
| 158 | return [:] | ||
| 159 | } | ||
| 160 | |||
| 161 | let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:] | ||
| 162 | return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in | ||
| 163 | let key = normalizedUserKey(entry.key) | ||
| 164 | guard !key.isEmpty else { return } | ||
| 165 | let pins = normalizedPins(entry.value) | ||
| 166 | if !pins.isEmpty { | ||
| 167 | result[key] = pins | ||
| 168 | } | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) { | ||
| 173 | var allPins = loadAll(defaults: defaults) | ||
| 174 | allPins[userKey] = normalizedPins(pins) | ||
| 175 | saveAll(allPins, defaults: defaults) | ||
| 176 | } | ||
| 177 | |||
| 178 | private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) { | ||
| 179 | guard let data = try? JSONEncoder().encode(pinsByUser) else { return } | ||
| 180 | defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems) | ||
| 181 | } | ||
| 182 | |||
| 183 | private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] { | ||
| 184 | var seen = Set<String>() | ||
| 185 | return pins.compactMap { pin in | ||
| 186 | let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 187 | let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 188 | let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 189 | guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil } | ||
| 190 | |||
| 191 | let normalized = HomePinRecord( | ||
| 192 | kind: pin.kind, | ||
| 193 | value: value, | ||
| 194 | title: title, | ||
| 195 | subtitle: subtitle, | ||
| 196 | ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| 197 | service: pin.service | ||
| 198 | ) | ||
| 199 | guard seen.insert(normalized.id).inserted else { return nil } | ||
| 200 | return normalized | ||
| 201 | } | ||
| 202 | } | ||
| 203 | |||
| 204 | private static func normalizedUserKey(_ userKey: String) -> String { | ||
| 205 | userKey.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 206 | } | ||
| 207 | } | ||
Hutch/Views/Home/HomePrototypeView.swift added +7
| @@ -0,0 +1,7 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct HomePrototypeView: View { | ||
| 4 | var body: some View { | ||
| 5 | HomeView() | ||
| 6 | } | ||
| 7 | } | ||
Hutch/Views/Home/HomeView.swift +381 −735
| @@ -1,15 +1,13 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct HomeView: View { | 3 | struct HomeView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | ||
| 5 | @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true | ||
| 6 | @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true | ||
| 7 | @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true | ||
| 8 | @Environment(AppState.self) private var appState | 4 | @Environment(AppState.self) private var appState |
| 9 | @Environment(\.scenePhase) private var scenePhase | 5 | @Environment(\.scenePhase) private var scenePhase |
| 10 | @State private var viewModel: HomeViewModel? | 6 | @State private var viewModel: HomeViewModel? |
| 11 | private let previewLimit = 4 | 7 | @State private var recentItems: [RecentActivityEntry] = [] |
| 12 | private let projectPreviewLimit = 3 | 8 | @State private var isOpeningRecentItem = false |
| 9 | @State private var selectedPinnedProject: Project? | ||
| 10 | @State private var selectedPinnedUser: User? | ||
| 13 | 11 | ||
| 14 | var body: some View { | 12 | var body: some View { |
| 15 | Group { | 13 | Group { |
| @@ -20,39 +18,40 @@ struct HomeView: View { | |||
| 20 | } | 18 | } |
| 21 | } | 19 | } |
| 22 | .navigationTitle("Home") | 20 | .navigationTitle("Home") |
| 23 | .toolbar { | 21 | .navigationDestination(isPresented: Binding( |
| 24 | ToolbarItem(placement: .topBarTrailing) { | 22 | get: { selectedPinnedProject != nil }, |
| 25 | NavigationLink { | 23 | set: { isPresented in |
| 26 | InboxView() | 24 | if !isPresented { |
| 27 | } label: { | 25 | selectedPinnedProject = nil |
| 28 | HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true) | ||
| 29 | } | 26 | } |
| 30 | } | 27 | } |
| 28 | )) { | ||
| 29 | if let selectedPinnedProject { | ||
| 30 | ProjectDetailView(project: selectedPinnedProject) | ||
| 31 | } | ||
| 32 | } | ||
| 33 | .navigationDestination(isPresented: Binding( | ||
| 34 | get: { selectedPinnedUser != nil }, | ||
| 35 | set: { isPresented in | ||
| 36 | if !isPresented { | ||
| 37 | selectedPinnedUser = nil | ||
| 38 | } | ||
| 39 | } | ||
| 40 | )) { | ||
| 41 | if let selectedPinnedUser { | ||
| 42 | UserProfileView(user: selectedPinnedUser) | ||
| 43 | } | ||
| 31 | } | 44 | } |
| 32 | .task { | 45 | .task { |
| 33 | guard let currentUser = appState.currentUser else { return } | 46 | guard let currentUser = appState.currentUser else { return } |
| 34 | 47 | await ensureViewModel(currentUser: currentUser).loadDashboard() | |
| 35 | let vm: HomeViewModel | 48 | loadRecentActivity() |
| 36 | if let viewModel { | ||
| 37 | vm = viewModel | ||
| 38 | } else { | ||
| 39 | let newViewModel = HomeViewModel( | ||
| 40 | currentUser: currentUser, | ||
| 41 | client: appState.client, | ||
| 42 | systemStatusRepository: appState.systemStatusRepository, | ||
| 43 | defaults: appState.accountDefaults, | ||
| 44 | accountID: appState.activeAccountID | ||
| 45 | ) | ||
| 46 | viewModel = newViewModel | ||
| 47 | vm = newViewModel | ||
| 48 | } | ||
| 49 | |||
| 50 | await vm.loadDashboard() | ||
| 51 | } | 49 | } |
| 52 | .onChange(of: scenePhase) { _, newPhase in | 50 | .onChange(of: scenePhase) { _, newPhase in |
| 53 | guard newPhase == .active, let viewModel else { return } | 51 | guard newPhase == .active, let viewModel else { return } |
| 54 | Task { | 52 | Task { |
| 55 | await viewModel.loadDashboard() | 53 | await viewModel.loadDashboard() |
| 54 | loadRecentActivity() | ||
| 56 | } | 55 | } |
| 57 | } | 56 | } |
| 58 | } | 57 | } |
| @@ -60,850 +59,497 @@ struct HomeView: View { | |||
| 60 | @ViewBuilder | 59 | @ViewBuilder |
| 61 | private func content(_ viewModel: HomeViewModel) -> some View { | 60 | private func content(_ viewModel: HomeViewModel) -> some View { |
| 62 | List { | 61 | List { |
| 63 | attentionSection(viewModel) | 62 | systemStatusSection(viewModel) |
| 64 | systemStatusBannerSection(viewModel) | 63 | workSection(viewModel) |
| 65 | inboxSection(viewModel) | 64 | recentSection |
| 66 | projectsSection(viewModel) | 65 | buildsSection(viewModel) |
| 67 | assignedTicketsSection(viewModel) | 66 | pinnedSection(viewModel) |
| 68 | recentBuildsSection(viewModel) | ||
| 69 | } | 67 | } |
| 70 | .themedList() | 68 | .themedList() |
| 71 | .listStyle(.insetGrouped) | 69 | .listStyle(.insetGrouped) |
| 72 | .overlay { | 70 | .listSectionSpacing(.compact) |
| 73 | if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && | ||
| 74 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | ||
| 75 | viewModel.unreadInboxThreads.isEmpty { | ||
| 76 | SRHTLoadingStateView(message: "Loading Home…") | ||
| 77 | } | ||
| 78 | } | ||
| 79 | .refreshable { | 71 | .refreshable { |
| 80 | await viewModel.loadDashboard() | 72 | await viewModel.loadDashboard() |
| 81 | } | 73 | } |
| 82 | .connectivityOverlay(hasContent: viewModel.hasDashboardContent) { | 74 | .connectivityOverlay(hasContent: hasHomeContent(viewModel)) { |
| 83 | await viewModel.loadDashboard() | 75 | await viewModel.loadDashboard() |
| 84 | } | 76 | } |
| 85 | } | 77 | .onAppear { |
| 86 | 78 | loadRecentActivity() | |
| 87 | @ViewBuilder | ||
| 88 | private func attentionSection(_ viewModel: HomeViewModel) -> some View { | ||
| 89 | Section("Needs Attention") { | ||
| 90 | HomeAttentionSummaryRow( | ||
| 91 | title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention", | ||
| 92 | summary: viewModel.attentionSummaryText | ||
| 93 | ) | ||
| 94 | |||
| 95 | HomeAttentionLinkRow( | ||
| 96 | title: "Inbox", | ||
| 97 | summary: viewModel.inboxSummaryText, | ||
| 98 | countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?" | ||
| 99 | ) { | ||
| 100 | InboxView() | ||
| 101 | } | ||
| 102 | |||
| 103 | HomeAttentionLinkRow( | ||
| 104 | title: "Assigned Tickets", | ||
| 105 | summary: viewModel.ticketsSummaryText, | ||
| 106 | countText: String(viewModel.assignedTickets.count) | ||
| 107 | ) { | ||
| 108 | HomeAssignedTicketsListView(viewModel: viewModel) | ||
| 109 | } | ||
| 110 | |||
| 111 | HomeAttentionLinkRow( | ||
| 112 | title: "Builds", | ||
| 113 | summary: viewModel.buildsSummaryText, | ||
| 114 | countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), | ||
| 115 | action: { | ||
| 116 | appState.navigateToBuildsList() | ||
| 117 | } | ||
| 118 | ) | ||
| 119 | } | 79 | } |
| 120 | } | 80 | } |
| 121 | 81 | ||
| 122 | @ViewBuilder | 82 | @ViewBuilder |
| 123 | private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { | 83 | private func systemStatusSection(_ viewModel: HomeViewModel) -> some View { |
| 124 | Section { | 84 | if viewModel.systemStatusSnapshot?.hasDisruption == true { |
| 125 | NavigationLink { | 85 | Section { |
| 126 | SystemStatusView() | 86 | NavigationLink { |
| 127 | } label: { | 87 | SystemStatusView() |
| 128 | SystemStatusSummaryRow( | 88 | } label: { |
| 129 | snapshot: viewModel.systemStatusSnapshot, | 89 | SystemStatusSummaryRow( |
| 130 | isLoading: viewModel.isLoadingSystemStatus, | 90 | snapshot: viewModel.systemStatusSnapshot, |
| 131 | errorMessage: viewModel.systemStatusErrorMessage, | 91 | isLoading: viewModel.isLoadingSystemStatus, |
| 132 | isShowingStaleData: viewModel.isShowingStaleSystemStatus | 92 | errorMessage: viewModel.systemStatusErrorMessage, |
| 133 | ) | 93 | isShowingStaleData: viewModel.isShowingStaleSystemStatus |
| 94 | ) | ||
| 95 | } | ||
| 96 | .buttonStyle(.plain) | ||
| 134 | } | 97 | } |
| 135 | .buttonStyle(.plain) | ||
| 136 | } | 98 | } |
| 137 | } | 99 | } |
| 138 | 100 | ||
| 139 | @ViewBuilder | 101 | private func workSection(_ viewModel: HomeViewModel) -> some View { |
| 140 | private func inboxSection(_ viewModel: HomeViewModel) -> some View { | 102 | Section("Work") { |
| 141 | Section { | 103 | NavigationLink(value: HomeRoute.work) { |
| 142 | if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { | 104 | HomeSummaryRow( |
| 143 | HomeSectionMessageRow( | 105 | title: workTitle(viewModel), |
| 144 | text: "No unread inbox threads.", | 106 | summary: workSummary(viewModel), |
| 145 | systemImage: "tray" | 107 | systemImage: "tray.full", |
| 146 | ) | 108 | tint: workCount(viewModel) > 0 ? .blue : .secondary, |
| 147 | } else if viewModel.unreadInboxThreads.isEmpty { | 109 | emphasis: .action |
| 148 | HomeSectionMessageRow( | ||
| 149 | text: viewModel.inboxSummaryText, | ||
| 150 | systemImage: "tray" | ||
| 151 | ) | 110 | ) |
| 152 | } else { | ||
| 153 | ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in | ||
| 154 | NavigationLink { | ||
| 155 | ThreadDetailView( | ||
| 156 | thread: thread, | ||
| 157 | onViewed: { viewModel.markInboxThreadRead(thread) }, | ||
| 158 | onMarkRead: { viewModel.markInboxThreadRead(thread) }, | ||
| 159 | onMarkUnread: { viewModel.markInboxThreadUnread(thread) } | ||
| 160 | ) | ||
| 161 | } label: { | ||
| 162 | HomeInboxThreadRow(thread: thread) | ||
| 163 | } | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } header: { | ||
| 167 | HomeSectionHeader("Inbox") { | ||
| 168 | InboxView() | ||
| 169 | } | 111 | } |
| 170 | } | 112 | } |
| 171 | } | 113 | } |
| 172 | 114 | ||
| 173 | @ViewBuilder | 115 | @ViewBuilder |
| 174 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { | 116 | private var recentSection: some View { |
| 175 | if viewModel.hasPinnedProjects { | 117 | if !recentItems.isEmpty { |
| 176 | HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) { | 118 | Section("Recent") { |
| 177 | NavigationLink { | 119 | ForEach(recentItems) { item in |
| 178 | ProjectsListView() | 120 | Button { |
| 179 | } label: { | 121 | openRecentItem(item) |
| 180 | Text("See All") | 122 | } label: { |
| 181 | .font(.caption.weight(.medium)) | 123 | HomeRecentRow(item: item) |
| 182 | } | ||
| 183 | .buttonStyle(.plain) | ||
| 184 | } content: { | ||
| 185 | if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty { | ||
| 186 | HomeSectionLoadingRow(label: "Loading pinned projects") | ||
| 187 | } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty { | ||
| 188 | HomeSectionMessageRow( | ||
| 189 | text: "Couldn’t load pinned projects.", | ||
| 190 | systemImage: "exclamationmark.triangle", | ||
| 191 | emphasized: true, | ||
| 192 | accessibilityHint: error | ||
| 193 | ) | ||
| 194 | } else if viewModel.pinnedProjects.isEmpty { | ||
| 195 | HomeSectionMessageRow( | ||
| 196 | text: "Pinned projects will appear here when they’re available.", | ||
| 197 | systemImage: "pin" | ||
| 198 | ) | ||
| 199 | } else { | ||
| 200 | ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in | ||
| 201 | NavigationLink { | ||
| 202 | ProjectDetailView(project: project) | ||
| 203 | } label: { | ||
| 204 | HomeProjectRow(project: project) | ||
| 205 | } | ||
| 206 | } | 124 | } |
| 125 | .buttonStyle(.plain) | ||
| 126 | .disabled(isOpeningRecentItem) | ||
| 127 | .listRowSeparator(.hidden) | ||
| 207 | } | 128 | } |
| 208 | } | 129 | } |
| 209 | } | 130 | } |
| 210 | } | 131 | } |
| 211 | 132 | ||
| 212 | @ViewBuilder | 133 | private func buildsSection(_ viewModel: HomeViewModel) -> some View { |
| 213 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { | 134 | Section("Builds") { |
| 214 | HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) { | ||
| 215 | NavigationLink { | 135 | NavigationLink { |
| 216 | HomeAssignedTicketsListView(viewModel: viewModel) | 136 | BuildListView() |
| 217 | } label: { | 137 | } label: { |
| 218 | Text("See All") | 138 | HomeSummaryRow( |
| 219 | .font(.caption.weight(.medium)) | 139 | title: buildsTitle(viewModel), |
| 220 | } | 140 | summary: buildsSummary(viewModel), |
| 221 | .buttonStyle(.plain) | 141 | systemImage: "hammer", |
| 222 | } content: { | 142 | tint: viewModel.failedBuildCount > 0 ? .orange : .secondary, |
| 223 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | 143 | emphasis: .monitoring |
| 224 | HomeSectionLoadingRow(label: "Loading assigned tickets") | ||
| 225 | } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { | ||
| 226 | HomeSectionMessageRow( | ||
| 227 | text: "Couldn’t load assigned tickets.", | ||
| 228 | systemImage: "exclamationmark.triangle", | ||
| 229 | emphasized: true, | ||
| 230 | accessibilityHint: error | ||
| 231 | ) | ||
| 232 | } else if viewModel.assignedTickets.isEmpty { | ||
| 233 | HomeSectionMessageRow( | ||
| 234 | text: "No open tickets assigned to you.", | ||
| 235 | systemImage: "person.crop.circle.badge.checkmark" | ||
| 236 | ) | 144 | ) |
| 237 | } else { | ||
| 238 | ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in | ||
| 239 | NavigationLink { | ||
| 240 | TicketDetailView( | ||
| 241 | ownerUsername: ticket.ownerUsername, | ||
| 242 | trackerName: ticket.trackerName, | ||
| 243 | trackerId: ticket.trackerId, | ||
| 244 | trackerRid: ticket.trackerRid, | ||
| 245 | ticketId: ticket.ticket.id | ||
| 246 | ) | ||
| 247 | } label: { | ||
| 248 | HomeAssignedTicketRow(ticket: ticket) | ||
| 249 | } | ||
| 250 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 251 | if swipeActionsEnabled { | ||
| 252 | ticketLeadingSwipeAction(ticket, viewModel: viewModel) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | ||
| 256 | if swipeActionsEnabled { | ||
| 257 | Button { | ||
| 258 | Task { | ||
| 259 | await viewModel.unassignFromMe(ticket) | ||
| 260 | } | ||
| 261 | } label: { | ||
| 262 | Label("Unassign Me", systemImage: "person.badge.minus") | ||
| 263 | } | ||
| 264 | .tint(.orange) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | } | 145 | } |
| 269 | } | 146 | } |
| 270 | } | 147 | } |
| 271 | 148 | ||
| 272 | @ViewBuilder | 149 | private func pinnedSection(_ viewModel: HomeViewModel) -> some View { |
| 273 | private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { | 150 | let items = pinnedItems(viewModel) |
| 274 | HomeSectionView("Builds", isExpanded: $buildsExpanded) { | ||
| 275 | Button("See All") { | ||
| 276 | appState.navigateToBuildsList() | ||
| 277 | } | ||
| 278 | .font(.caption.weight(.medium)) | ||
| 279 | .buttonStyle(.plain) | ||
| 280 | } content: { | ||
| 281 | if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { | ||
| 282 | HomeSectionLoadingRow(label: "Loading recent builds") | ||
| 283 | } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { | ||
| 284 | HomeSectionMessageRow( | ||
| 285 | text: "Couldn’t load recent builds.", | ||
| 286 | systemImage: "exclamationmark.triangle", | ||
| 287 | emphasized: true, | ||
| 288 | accessibilityHint: error | ||
| 289 | ) | ||
| 290 | } else if viewModel.recentBuilds.isEmpty { | ||
| 291 | HomeSectionMessageRow( | ||
| 292 | text: "No recent builds.", | ||
| 293 | systemImage: "clock" | ||
| 294 | ) | ||
| 295 | } else { | ||
| 296 | ForEach(buildGroups(for: viewModel.recentBuilds)) { group in | ||
| 297 | if let repositoryDisplayName = group.repositoryDisplayName { | ||
| 298 | HomeBuildGroupHeader( | ||
| 299 | repositoryDisplayName: repositoryDisplayName, | ||
| 300 | buildCount: group.builds.count, | ||
| 301 | latestStatus: group.latestStatus | ||
| 302 | ) | ||
| 303 | } | ||
| 304 | 151 | ||
| 305 | ForEach(group.builds) { build in | 152 | return Section("Pinned") { |
| 306 | NavigationLink { | 153 | if items.isEmpty { |
| 307 | BuildDetailView(jobId: build.job.id) | 154 | NavigationLink { |
| 155 | ProjectsListView() | ||
| 156 | } label: { | ||
| 157 | HomeCompactMessageRow(text: "Pin projects for quick access", systemImage: "pin") | ||
| 158 | } | ||
| 159 | } else { | ||
| 160 | LazyVGrid( | ||
| 161 | columns: [ | ||
| 162 | GridItem(.flexible(), spacing: 10), | ||
| 163 | GridItem(.flexible(), spacing: 10), | ||
| 164 | ], | ||
| 165 | spacing: 10 | ||
| 166 | ) { | ||
| 167 | ForEach(items) { item in | ||
| 168 | Button { | ||
| 169 | openPinnedItem(item, viewModel: viewModel) | ||
| 308 | } label: { | 170 | } label: { |
| 309 | HomeBuildRow( | 171 | HomePinnedCard(item: item) |
| 310 | build: build, | ||
| 311 | showsRepositoryLink: group.repositoryDisplayName == nil | ||
| 312 | ) | ||
| 313 | } | ||
| 314 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 315 | if swipeActionsEnabled, build.job.status.isCancellable { | ||
| 316 | Button { | ||
| 317 | Task { | ||
| 318 | await viewModel.cancelBuild(build) | ||
| 319 | } | ||
| 320 | } | ||
| 321 | label: { | ||
| 322 | Label("Cancel", systemImage: "xmark.circle") | ||
| 323 | } | ||
| 324 | .tint(.red) | ||
| 325 | } | ||
| 326 | } | 172 | } |
| 173 | .buttonStyle(.plain) | ||
| 327 | } | 174 | } |
| 328 | } | 175 | } |
| 176 | .padding(.vertical, 2) | ||
| 329 | } | 177 | } |
| 330 | } | 178 | } |
| 331 | } | 179 | } |
| 332 | 180 | ||
| 333 | private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] { | 181 | private func workCount(_ viewModel: HomeViewModel) -> Int { |
| 334 | let previewBuilds = Array(builds.prefix(previewLimit)) | 182 | unreadCount(viewModel) + viewModel.assignedTickets.count |
| 335 | guard let firstBuild = previewBuilds.first else { return [] } | 183 | } |
| 336 | |||
| 337 | var groups: [HomeBuildGroup] = [] | ||
| 338 | var currentIdentity = HomeBuildGroup.Identity(build: firstBuild) | ||
| 339 | var currentBuilds: [HomeBuildItem] = [] | ||
| 340 | |||
| 341 | for build in previewBuilds { | ||
| 342 | let identity = HomeBuildGroup.Identity(build: build) | ||
| 343 | if identity == currentIdentity { | ||
| 344 | currentBuilds.append(build) | ||
| 345 | } else { | ||
| 346 | groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) | ||
| 347 | currentIdentity = identity | ||
| 348 | currentBuilds = [build] | ||
| 349 | } | ||
| 350 | } | ||
| 351 | |||
| 352 | if !currentBuilds.isEmpty { | ||
| 353 | groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) | ||
| 354 | } | ||
| 355 | 184 | ||
| 356 | return groups | 185 | private func unreadCount(_ viewModel: HomeViewModel) -> Int { |
| 186 | viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count | ||
| 357 | } | 187 | } |
| 358 | 188 | ||
| 359 | @ViewBuilder | 189 | private func workTitle(_ viewModel: HomeViewModel) -> String { |
| 360 | private func ticketLeadingSwipeAction( | 190 | let count = workCount(viewModel) |
| 361 | _ ticket: HomeAssignedTicket, | 191 | if count == 0 { |
| 362 | viewModel: HomeViewModel | 192 | return "Queue clear" |
| 363 | ) -> some View { | ||
| 364 | if ticket.ticket.status.isOpen { | ||
| 365 | Button { | ||
| 366 | Task { | ||
| 367 | await viewModel.resolveTicket(ticket) | ||
| 368 | } | ||
| 369 | } label: { | ||
| 370 | Label("Resolve", systemImage: "checkmark.circle") | ||
| 371 | } | ||
| 372 | .tint(.green) | ||
| 373 | } else { | ||
| 374 | Button { | ||
| 375 | Task { | ||
| 376 | await viewModel.reopenTicket(ticket) | ||
| 377 | } | ||
| 378 | } label: { | ||
| 379 | Label("Reopen", systemImage: "arrow.uturn.backward") | ||
| 380 | } | ||
| 381 | .tint(.blue) | ||
| 382 | } | 193 | } |
| 194 | return "\(count) item\(count == 1 ? "" : "s") need attention" | ||
| 383 | } | 195 | } |
| 384 | 196 | ||
| 385 | } | 197 | private func workSummary(_ viewModel: HomeViewModel) -> String { |
| 386 | 198 | let unread = unreadCount(viewModel) | |
| 387 | private struct HomeInboxToolbarIcon: View { | 199 | let assigned = viewModel.assignedTickets.count |
| 388 | let hasUnreadThreads: Bool | 200 | return "\(unread) unread • \(assigned) assigned" |
| 389 | |||
| 390 | var body: some View { | ||
| 391 | Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") | ||
| 392 | .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox") | ||
| 393 | } | 201 | } |
| 394 | } | ||
| 395 | |||
| 396 | private struct HomeProjectRow: View { | ||
| 397 | let project: Project | ||
| 398 | |||
| 399 | var body: some View { | ||
| 400 | VStack(alignment: .leading, spacing: 8) { | ||
| 401 | HStack(alignment: .top, spacing: 10) { | ||
| 402 | VStack(alignment: .leading, spacing: 4) { | ||
| 403 | Text(project.displayName) | ||
| 404 | .font(.subheadline.weight(.medium)) | ||
| 405 | .foregroundStyle(.primary) | ||
| 406 | .lineLimit(1) | ||
| 407 | |||
| 408 | if let description = project.displayDescription { | ||
| 409 | Text(description) | ||
| 410 | .font(.caption) | ||
| 411 | .foregroundStyle(.secondary) | ||
| 412 | .lineLimit(2) | ||
| 413 | } | ||
| 414 | } | ||
| 415 | 202 | ||
| 416 | Spacer(minLength: 8) | 203 | private func buildsTitle(_ viewModel: HomeViewModel) -> String { |
| 204 | let failed = viewModel.failedBuildCount | ||
| 205 | let running = viewModel.activeBuildCount | ||
| 417 | 206 | ||
| 418 | VisibilityBadge(visibility: project.visibility) | 207 | if failed == 0 && running == 0 { |
| 419 | } | 208 | return "Build monitoring clear" |
| 420 | |||
| 421 | Text(project.metadataLine) | ||
| 422 | .font(.caption) | ||
| 423 | .foregroundStyle(.secondary) | ||
| 424 | .lineLimit(1) | ||
| 425 | } | 209 | } |
| 426 | .contentShape(Rectangle()) | 210 | if failed > 0 { |
| 427 | .padding(.vertical, 4) | 211 | return "\(failed) failed build\(failed == 1 ? "" : "s")" |
| 428 | } | ||
| 429 | } | ||
| 430 | |||
| 431 | private struct HomeBuildRow: View { | ||
| 432 | @Environment(AppState.self) private var appState | ||
| 433 | let build: HomeBuildItem | ||
| 434 | var showsRepositoryLink = true | ||
| 435 | |||
| 436 | var body: some View { | ||
| 437 | VStack(alignment: .leading, spacing: 6) { | ||
| 438 | HStack(spacing: 12) { | ||
| 439 | JobStatusIcon(status: build.job.status) | ||
| 440 | .frame(width: 20) | ||
| 441 | |||
| 442 | VStack(alignment: .leading, spacing: 4) { | ||
| 443 | Text(build.job.displayLabel) | ||
| 444 | .font(.subheadline.weight(.medium)) | ||
| 445 | .lineLimit(1) | ||
| 446 | |||
| 447 | HStack(spacing: 8) { | ||
| 448 | Text("Job #\(build.job.id)") | ||
| 449 | .font(.caption) | ||
| 450 | .foregroundStyle(.secondary) | ||
| 451 | |||
| 452 | Text("•") | ||
| 453 | .font(.caption) | ||
| 454 | .foregroundStyle(.tertiary) | ||
| 455 | |||
| 456 | Text(build.job.status.displayTitle) | ||
| 457 | .font(.caption) | ||
| 458 | .foregroundStyle(.secondary) | ||
| 459 | |||
| 460 | Text("•") | ||
| 461 | .font(.caption) | ||
| 462 | .foregroundStyle(.tertiary) | ||
| 463 | |||
| 464 | Text(build.job.created.relativeDescription) | ||
| 465 | .font(.caption) | ||
| 466 | .foregroundStyle(.tertiary) | ||
| 467 | |||
| 468 | Spacer() | ||
| 469 | } | ||
| 470 | } | ||
| 471 | } | ||
| 472 | |||
| 473 | if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName { | ||
| 474 | Button { | ||
| 475 | openRepository() | ||
| 476 | } label: { | ||
| 477 | Label(repositoryDisplayName, systemImage: "book.closed") | ||
| 478 | .font(.caption) | ||
| 479 | .foregroundStyle(.secondary) | ||
| 480 | } | ||
| 481 | .buttonStyle(.plain) | ||
| 482 | } | ||
| 483 | } | 212 | } |
| 484 | .padding(.vertical, 2) | 213 | return "\(running) running build\(running == 1 ? "" : "s")" |
| 485 | } | 214 | } |
| 486 | 215 | ||
| 487 | private func openRepository() { | 216 | private func buildsSummary(_ viewModel: HomeViewModel) -> String { |
| 488 | guard let repositoryName = build.repositoryName, | 217 | let failed = viewModel.failedBuildCount |
| 489 | let repositoryOwner = build.repositoryOwner else { return } | 218 | let running = viewModel.activeBuildCount |
| 490 | Task { | 219 | if failed == 0 && running == 0 { |
| 491 | do { | 220 | return "No failures • \(buildTimeframeLabel(viewModel))" |
| 492 | let repository = try await appState.resolveRepository( | 221 | } |
| 493 | owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, | 222 | if failed > 0 && running > 0 { |
| 494 | name: repositoryName | 223 | return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))" |
| 495 | ) | ||
| 496 | appState.navigateToRepository(repository) | ||
| 497 | } catch { | ||
| 498 | appState.presentRepositoryDeepLinkError() | ||
| 499 | } | ||
| 500 | } | 224 | } |
| 225 | if failed > 0 { | ||
| 226 | return "\(failed) failed • \(buildTimeframeLabel(viewModel))" | ||
| 227 | } | ||
| 228 | return "\(running) running • \(buildTimeframeLabel(viewModel))" | ||
| 501 | } | 229 | } |
| 502 | } | ||
| 503 | 230 | ||
| 504 | private struct HomeBuildGroup: Identifiable { | 231 | private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] { |
| 505 | enum Identity: Hashable { | 232 | let currentUserKey = appState.currentUser?.canonicalName ?? "" |
| 506 | case repository(owner: String?, name: String) | 233 | let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults) |
| 507 | case standalone(Int) | 234 | let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) }) |
| 508 | 235 | ||
| 509 | init(build: HomeBuildItem) { | 236 | return pins.compactMap { pin in |
| 510 | if let repositoryName = build.repositoryName { | 237 | switch pin.kind { |
| 511 | self = .repository(owner: build.repositoryOwner, name: repositoryName) | 238 | case .project: |
| 512 | } else { | 239 | guard let project = projectsByID[pin.value] else { return nil } |
| 513 | self = .standalone(build.id) | 240 | return HomePinnedItem(pin: pin, project: project) |
| 241 | case .repository, .tracker, .mailingList, .user: | ||
| 242 | return HomePinnedItem(pin: pin, project: nil) | ||
| 514 | } | 243 | } |
| 515 | } | 244 | } |
| 516 | } | 245 | } |
| 517 | 246 | ||
| 518 | let identity: Identity | 247 | private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String { |
| 519 | let builds: [HomeBuildItem] | 248 | let calendar = Calendar.current |
| 249 | let buildDates = viewModel.recentBuilds.map(\.job.updated) | ||
| 520 | 250 | ||
| 521 | var id: String { | 251 | guard !buildDates.isEmpty else { |
| 522 | switch identity { | 252 | return "today" |
| 523 | case .repository(let owner, let name): | ||
| 524 | return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)" | ||
| 525 | case .standalone(let jobId): | ||
| 526 | return "job-\(jobId)" | ||
| 527 | } | 253 | } |
| 528 | } | ||
| 529 | 254 | ||
| 530 | var repositoryDisplayName: String? { | 255 | return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week" |
| 531 | builds.first?.repositoryDisplayName | ||
| 532 | } | 256 | } |
| 533 | 257 | ||
| 534 | var latestStatus: JobStatus { | 258 | private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool { |
| 535 | builds.first?.job.status ?? .pending | 259 | viewModel.systemStatusSnapshot?.hasDisruption == true || |
| 260 | workCount(viewModel) > 0 || | ||
| 261 | !recentItems.isEmpty || | ||
| 262 | !pinnedItems(viewModel).isEmpty | ||
| 536 | } | 263 | } |
| 537 | } | ||
| 538 | |||
| 539 | private struct HomeBuildGroupHeader: View { | ||
| 540 | let repositoryDisplayName: String | ||
| 541 | let buildCount: Int | ||
| 542 | let latestStatus: JobStatus | ||
| 543 | 264 | ||
| 544 | var body: some View { | 265 | private func loadRecentActivity() { |
| 545 | HStack(spacing: 12) { | 266 | recentItems = RecentActivityStore.load(defaults: appState.accountDefaults) |
| 546 | Label(repositoryDisplayName, systemImage: "book.closed") | ||
| 547 | .font(.caption.weight(.medium)) | ||
| 548 | .foregroundStyle(.secondary) | ||
| 549 | .lineLimit(1) | ||
| 550 | |||
| 551 | Spacer(minLength: 8) | ||
| 552 | |||
| 553 | Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")") | ||
| 554 | .font(.caption2.weight(.medium)) | ||
| 555 | .foregroundStyle(.tertiary) | ||
| 556 | |||
| 557 | JobStatusBadge(status: latestStatus) | ||
| 558 | } | ||
| 559 | .padding(.top, 4) | ||
| 560 | .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20)) | ||
| 561 | .listRowSeparator(.hidden) | ||
| 562 | .accessibilityElement(children: .combine) | ||
| 563 | } | 267 | } |
| 564 | } | ||
| 565 | 268 | ||
| 566 | private struct HomeAssignedTicketRow: View { | 269 | private func openRecentItem(_ item: RecentActivityEntry) { |
| 567 | @Environment(AppState.self) private var appState | 270 | guard !isOpeningRecentItem else { return } |
| 568 | let ticket: HomeAssignedTicket | ||
| 569 | 271 | ||
| 570 | var body: some View { | 272 | switch item.kind { |
| 571 | VStack(alignment: .leading, spacing: 6) { | 273 | case .build: |
| 572 | HStack(alignment: .top, spacing: 12) { | 274 | guard let jobId = item.buildJobId else { return } |
| 573 | TicketStatusIcon(status: ticket.ticket.status) | 275 | appState.navigateToBuild(jobId: jobId) |
| 574 | .frame(width: 20) | 276 | case .ticket: |
| 575 | 277 | guard | |
| 576 | VStack(alignment: .leading, spacing: 4) { | 278 | let ownerUsername = item.ticketOwnerUsername, |
| 577 | Text(ticket.ticket.title) | 279 | let trackerName = item.ticketTrackerName, |
| 578 | .font(.subheadline.weight(.medium)) | 280 | let ticketId = item.ticketId |
| 579 | .lineLimit(2) | 281 | else { |
| 580 | 282 | return | |
| 581 | Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") | ||
| 582 | .font(.caption) | ||
| 583 | .foregroundStyle(.secondary) | ||
| 584 | .lineLimit(1) | ||
| 585 | .truncationMode(.tail) | ||
| 586 | } | ||
| 587 | |||
| 588 | Spacer(minLength: 8) | ||
| 589 | |||
| 590 | Text(ticket.ticket.status.displayName) | ||
| 591 | .font(.caption2.weight(.medium)) | ||
| 592 | .foregroundStyle(.secondary) | ||
| 593 | .lineLimit(1) | ||
| 594 | .fixedSize() | ||
| 595 | } | 283 | } |
| 596 | 284 | appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) | |
| 597 | Button { | 285 | case .repository: |
| 598 | openTracker() | 286 | guard |
| 599 | } label: { | 287 | let owner = item.repositoryOwner, |
| 600 | Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") | 288 | let name = item.repositoryName |
| 601 | .font(.caption) | 289 | else { |
| 602 | .foregroundStyle(.secondary) | 290 | return |
| 603 | } | 291 | } |
| 604 | .buttonStyle(.plain) | ||
| 605 | } | ||
| 606 | .padding(.vertical, 2) | ||
| 607 | } | ||
| 608 | 292 | ||
| 609 | private func openTracker() { | 293 | isOpeningRecentItem = true |
| 610 | Task { | 294 | Task { |
| 611 | do { | 295 | defer { isOpeningRecentItem = false } |
| 612 | let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) | 296 | do { |
| 613 | appState.navigateToTracker(tracker) | 297 | let repository = try await appState.resolveRepository( |
| 614 | } catch { | 298 | owner: owner, |
| 615 | appState.presentTicketDeepLinkError() | 299 | name: name, |
| 300 | service: item.repositoryService ?? .git | ||
| 301 | ) | ||
| 302 | appState.navigateToRepository(repository) | ||
| 303 | } catch { | ||
| 304 | appState.presentRepositoryDeepLinkError() | ||
| 305 | } | ||
| 616 | } | 306 | } |
| 617 | } | 307 | } |
| 618 | } | 308 | } |
| 619 | } | ||
| 620 | 309 | ||
| 621 | private struct HomeInboxThreadRow: View { | 310 | private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) { |
| 622 | @Environment(AppState.self) private var appState | 311 | switch item.pin.kind { |
| 623 | let thread: InboxThreadSummary | 312 | case .project: |
| 624 | 313 | guard let project = item.project else { return } | |
| 625 | var body: some View { | 314 | selectedPinnedProject = project |
| 626 | VStack(alignment: .leading, spacing: 6) { | 315 | case .repository: |
| 627 | HStack(alignment: .top, spacing: 10) { | 316 | guard |
| 628 | Circle() | 317 | let owner = item.pin.ownerUsername, |
| 629 | .fill(.blue) | 318 | let service = item.pin.service |
| 630 | .frame(width: 8, height: 8) | 319 | else { |
| 631 | .padding(.top, 6) | 320 | return |
| 632 | 321 | } | |
| 633 | VStack(alignment: .leading, spacing: 4) { | 322 | isOpeningRecentItem = true |
| 634 | Text(thread.displaySubject) | 323 | Task { |
| 635 | .font(.subheadline.weight(.medium)) | 324 | defer { isOpeningRecentItem = false } |
| 636 | .lineLimit(2) | 325 | do { |
| 637 | 326 | let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service) | |
| 638 | Text(thread.metadataLine) | 327 | appState.navigateToRepository(repository) |
| 639 | .font(.caption) | 328 | } catch { |
| 640 | .foregroundStyle(.secondary) | 329 | appState.presentRepositoryDeepLinkError() |
| 641 | .lineLimit(1) | ||
| 642 | } | 330 | } |
| 643 | } | 331 | } |
| 644 | 332 | case .tracker: | |
| 645 | HStack(spacing: 10) { | 333 | guard let owner = item.pin.ownerUsername else { return } |
| 646 | Button { | 334 | isOpeningRecentItem = true |
| 647 | appState.navigateToMailingList( | 335 | Task { |
| 648 | InboxMailingListReference( | 336 | defer { isOpeningRecentItem = false } |
| 649 | id: thread.listID, | 337 | do { |
| 650 | rid: thread.listRID, | 338 | let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value) |
| 651 | name: thread.listName, | 339 | appState.navigateToTracker(tracker) |
| 652 | owner: thread.listOwner | 340 | } catch { |
| 653 | ) | 341 | appState.presentTicketDeepLinkError() |
| 654 | ) | ||
| 655 | } label: { | ||
| 656 | Label(thread.listName, systemImage: "list.bullet") | ||
| 657 | .font(.caption) | ||
| 658 | .foregroundStyle(.secondary) | ||
| 659 | } | 342 | } |
| 660 | .buttonStyle(.plain) | 343 | } |
| 661 | 344 | case .mailingList: | |
| 662 | if let repo = thread.repo { | 345 | guard let ownerUsername = item.pin.ownerUsername else { return } |
| 663 | Button { | 346 | appState.openMailingList( |
| 664 | openRepository(named: repo) | 347 | InboxMailingListReference( |
| 665 | } label: { | 348 | id: 0, |
| 666 | Label(repo, systemImage: "book.closed") | 349 | rid: item.pin.value, |
| 667 | .font(.caption) | 350 | name: item.pin.title, |
| 668 | .foregroundStyle(.secondary) | 351 | owner: Entity(canonicalName: "~\(ownerUsername)") |
| 669 | } | 352 | ) |
| 670 | .buttonStyle(.plain) | 353 | ) |
| 354 | case .user: | ||
| 355 | guard let ownerUsername = item.pin.ownerUsername else { return } | ||
| 356 | isOpeningRecentItem = true | ||
| 357 | Task { | ||
| 358 | defer { isOpeningRecentItem = false } | ||
| 359 | if let user = try? await resolvePinnedUser(username: ownerUsername) { | ||
| 360 | selectedPinnedUser = user | ||
| 671 | } | 361 | } |
| 672 | } | 362 | } |
| 673 | } | 363 | } |
| 674 | .padding(.vertical, 2) | ||
| 675 | } | 364 | } |
| 676 | 365 | ||
| 677 | private func openRepository(named repositoryName: String) { | 366 | private func resolvePinnedUser(username: String) async throws -> User { |
| 678 | Task { | 367 | struct Response: Decodable, Sendable { |
| 679 | do { | 368 | let user: User |
| 680 | let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") | 369 | } |
| 681 | ? String(thread.listOwner.canonicalName.dropFirst()) | 370 | |
| 682 | : thread.listOwner.canonicalName | 371 | let query = """ |
| 683 | let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) | 372 | query userLookup($username: String!) { |
| 684 | appState.navigateToRepository(repository) | 373 | user: userByName(username: $username) { |
| 685 | } catch { | 374 | id |
| 686 | appState.presentRepositoryDeepLinkError() | 375 | created |
| 376 | updated | ||
| 377 | canonicalName | ||
| 378 | username | ||
| 379 | |||
| 380 | url | ||
| 381 | location | ||
| 382 | bio | ||
| 383 | avatar | ||
| 384 | pronouns | ||
| 385 | userType | ||
| 687 | } | 386 | } |
| 688 | } | 387 | } |
| 689 | } | 388 | """ |
| 690 | } | ||
| 691 | 389 | ||
| 692 | private struct HomeSectionLoadingRow: View { | 390 | let result = try await appState.client.execute( |
| 693 | let label: String | 391 | service: .meta, |
| 392 | query: query, | ||
| 393 | variables: ["username": username], | ||
| 394 | responseType: Response.self | ||
| 395 | ) | ||
| 396 | return result.user | ||
| 397 | } | ||
| 694 | 398 | ||
| 695 | var body: some View { | 399 | @MainActor |
| 696 | HStack(spacing: 10) { | 400 | private func ensureViewModel(currentUser: User) -> HomeViewModel { |
| 697 | ProgressView() | 401 | if let viewModel { |
| 698 | .controlSize(.small) | 402 | return viewModel |
| 699 | Text(label) | ||
| 700 | .foregroundStyle(.secondary) | ||
| 701 | } | 403 | } |
| 702 | .frame(maxWidth: .infinity, alignment: .leading) | 404 | |
| 405 | let newViewModel = HomeViewModel( | ||
| 406 | currentUser: currentUser, | ||
| 407 | client: appState.client, | ||
| 408 | systemStatusRepository: appState.systemStatusRepository, | ||
| 409 | defaults: appState.accountDefaults, | ||
| 410 | accountID: appState.activeAccountID | ||
| 411 | ) | ||
| 412 | viewModel = newViewModel | ||
| 413 | return newViewModel | ||
| 703 | } | 414 | } |
| 704 | } | 415 | } |
| 705 | 416 | ||
| 706 | private struct HomeSectionHeader<Destination: View>: View { | 417 | enum HomeRoute: Hashable { |
| 707 | let title: String | 418 | case work |
| 708 | let destination: Destination | 419 | } |
| 709 | 420 | ||
| 710 | init(_ title: String, @ViewBuilder destination: () -> Destination) { | 421 | private enum HomeSummaryEmphasis { |
| 711 | self.title = title | 422 | case action |
| 712 | self.destination = destination() | 423 | case monitoring |
| 713 | } | 424 | } |
| 714 | 425 | ||
| 715 | var body: some View { | 426 | private struct HomePinnedItem: Identifiable { |
| 716 | HStack { | 427 | let pin: HomePinRecord |
| 717 | Text(title) | 428 | let project: Project? |
| 718 | Spacer() | 429 | |
| 719 | NavigationLink { | 430 | var id: String { pin.id } |
| 720 | destination | 431 | var title: String { project?.displayName ?? pin.title } |
| 721 | } label: { | 432 | var detail: String { pin.subtitle } |
| 722 | Text("See All") | ||
| 723 | .font(.caption.weight(.medium)) | ||
| 724 | } | ||
| 725 | .buttonStyle(.plain) | ||
| 726 | } | ||
| 727 | .textCase(nil) | ||
| 728 | } | ||
| 729 | } | 433 | } |
| 730 | 434 | ||
| 731 | private struct HomeAttentionSummaryRow: View { | 435 | private struct HomeSummaryRow: View { |
| 732 | let title: String | 436 | let title: String |
| 733 | let summary: String | 437 | let summary: String |
| 438 | let systemImage: String | ||
| 439 | let tint: Color | ||
| 440 | let emphasis: HomeSummaryEmphasis | ||
| 734 | 441 | ||
| 735 | var body: some View { | 442 | var body: some View { |
| 736 | VStack(alignment: .leading, spacing: 4) { | 443 | HStack(spacing: 10) { |
| 737 | Text(title) | 444 | Image(systemName: systemImage) |
| 738 | .font(.subheadline.weight(.semibold)) | 445 | .font(.subheadline.weight(.semibold)) |
| 739 | Text(summary) | 446 | .foregroundStyle(iconColor) |
| 740 | .font(.caption) | 447 | .frame(width: 18) |
| 741 | .foregroundStyle(.secondary) | 448 | |
| 742 | .lineLimit(2) | 449 | VStack(alignment: .leading, spacing: 2) { |
| 450 | Text(title) | ||
| 451 | .font(.subheadline.weight(.semibold)) | ||
| 452 | Text(summary) | ||
| 453 | .font(.caption) | ||
| 454 | .foregroundStyle(.secondary) | ||
| 455 | .lineLimit(1) | ||
| 456 | } | ||
| 457 | |||
| 458 | Spacer(minLength: 8) | ||
| 743 | } | 459 | } |
| 744 | .padding(.vertical, 2) | 460 | .padding(.vertical, verticalPadding) |
| 745 | } | 461 | } |
| 746 | } | ||
| 747 | 462 | ||
| 748 | private struct HomeAttentionLinkRow<Destination: View>: View { | 463 | private var iconColor: Color { |
| 749 | let title: String | 464 | switch emphasis { |
| 750 | let summary: String | 465 | case .action: |
| 751 | let countText: String | 466 | return tint |
| 752 | let destination: Destination? | 467 | case .monitoring: |
| 753 | let action: (() -> Void)? | 468 | return tint.opacity(0.9) |
| 754 | 469 | } | |
| 755 | init( | ||
| 756 | title: String, | ||
| 757 | summary: String, | ||
| 758 | countText: String, | ||
| 759 | @ViewBuilder destination: () -> Destination | ||
| 760 | ) { | ||
| 761 | self.title = title | ||
| 762 | self.summary = summary | ||
| 763 | self.countText = countText | ||
| 764 | self.destination = destination() | ||
| 765 | self.action = nil | ||
| 766 | } | 470 | } |
| 767 | 471 | ||
| 768 | init( | 472 | private var verticalPadding: CGFloat { |
| 769 | title: String, | 473 | switch emphasis { |
| 770 | summary: String, | 474 | case .action: |
| 771 | countText: String, | 475 | return 3 |
| 772 | action: @escaping () -> Void | 476 | case .monitoring: |
| 773 | ) where Destination == EmptyView { | 477 | return 2 |
| 774 | self.title = title | 478 | } |
| 775 | self.summary = summary | ||
| 776 | self.countText = countText | ||
| 777 | self.destination = nil | ||
| 778 | self.action = action | ||
| 779 | } | 479 | } |
| 480 | } | ||
| 481 | |||
| 482 | private struct HomeRecentRow: View { | ||
| 483 | let item: RecentActivityEntry | ||
| 780 | 484 | ||
| 781 | var body: some View { | 485 | var body: some View { |
| 782 | Group { | 486 | HStack(spacing: 10) { |
| 783 | if let destination { | 487 | Image(systemName: iconName) |
| 784 | NavigationLink { | 488 | .font(.caption.weight(.semibold)) |
| 785 | destination | 489 | .foregroundStyle(.secondary) |
| 786 | } label: { | 490 | .frame(width: 16) |
| 787 | content | ||
| 788 | } | ||
| 789 | } else if let action { | ||
| 790 | Button(action: action) { | ||
| 791 | content | ||
| 792 | } | ||
| 793 | .buttonStyle(.plain) | ||
| 794 | } | ||
| 795 | } | ||
| 796 | } | ||
| 797 | 491 | ||
| 798 | private var content: some View { | 492 | VStack(alignment: .leading, spacing: 1) { |
| 799 | HStack(spacing: 12) { | 493 | Text(item.title) |
| 800 | VStack(alignment: .leading, spacing: 4) { | ||
| 801 | Text(title) | ||
| 802 | .font(.subheadline.weight(.medium)) | 494 | .font(.subheadline.weight(.medium)) |
| 803 | Text(summary) | 495 | .lineLimit(1) |
| 496 | Text(item.detailText) | ||
| 804 | .font(.caption) | 497 | .font(.caption) |
| 805 | .foregroundStyle(.secondary) | 498 | .foregroundStyle(.secondary) |
| 806 | .lineLimit(1) | 499 | .lineLimit(1) |
| 807 | } | 500 | } |
| 808 | Spacer() | ||
| 809 | Text(countText) | ||
| 810 | .font(.caption.weight(.semibold)) | ||
| 811 | .foregroundStyle(.secondary) | ||
| 812 | .padding(.horizontal, 8) | ||
| 813 | .padding(.vertical, 4) | ||
| 814 | .background(Color(.secondarySystemFill), in: Capsule()) | ||
| 815 | 501 | ||
| 816 | if action != nil { | 502 | Spacer(minLength: 8) |
| 817 | Image(systemName: "chevron.right") | 503 | } |
| 818 | .font(.caption.weight(.semibold)) | 504 | .padding(.vertical, 1) |
| 819 | .foregroundStyle(.tertiary) | 505 | } |
| 820 | } | 506 | |
| 507 | private var iconName: String { | ||
| 508 | switch item.kind { | ||
| 509 | case .repository: | ||
| 510 | return "book.closed" | ||
| 511 | case .ticket: | ||
| 512 | return "number" | ||
| 513 | case .build: | ||
| 514 | return "hammer" | ||
| 821 | } | 515 | } |
| 822 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 823 | .contentShape(Rectangle()) | ||
| 824 | .padding(.vertical, 2) | ||
| 825 | } | 516 | } |
| 826 | } | 517 | } |
| 827 | 518 | ||
| 828 | private struct HomeAssignedTicketsListView: View { | 519 | private struct HomePinnedCard: View { |
| 829 | let viewModel: HomeViewModel | 520 | let item: HomePinnedItem |
| 830 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | ||
| 831 | 521 | ||
| 832 | var body: some View { | 522 | var body: some View { |
| 833 | List { | 523 | VStack(alignment: .leading, spacing: 6) { |
| 834 | ForEach(viewModel.assignedTickets) { ticket in | 524 | HStack(spacing: 6) { |
| 835 | NavigationLink { | 525 | Image(systemName: "square.stack.3d.up") |
| 836 | TicketDetailView( | 526 | .font(.caption.weight(.semibold)) |
| 837 | ownerUsername: ticket.ownerUsername, | 527 | .foregroundStyle(.secondary) |
| 838 | trackerName: ticket.trackerName, | 528 | Text(item.detail) |
| 839 | trackerId: ticket.trackerId, | 529 | .font(.caption2.weight(.semibold)) |
| 840 | trackerRid: ticket.trackerRid, | 530 | .foregroundStyle(.secondary) |
| 841 | ticketId: ticket.ticket.id | ||
| 842 | ) | ||
| 843 | } label: { | ||
| 844 | HomeAssignedTicketRow(ticket: ticket) | ||
| 845 | } | ||
| 846 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 847 | if swipeActionsEnabled { | ||
| 848 | if ticket.ticket.status.isOpen { | ||
| 849 | Button { | ||
| 850 | Task { await viewModel.resolveTicket(ticket) } | ||
| 851 | } label: { | ||
| 852 | Label("Resolve", systemImage: "checkmark.circle") | ||
| 853 | } | ||
| 854 | .tint(.green) | ||
| 855 | } else { | ||
| 856 | Button { | ||
| 857 | Task { await viewModel.reopenTicket(ticket) } | ||
| 858 | } label: { | ||
| 859 | Label("Reopen", systemImage: "arrow.uturn.backward") | ||
| 860 | } | ||
| 861 | .tint(.blue) | ||
| 862 | } | ||
| 863 | } | ||
| 864 | } | ||
| 865 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | ||
| 866 | if swipeActionsEnabled { | ||
| 867 | Button { | ||
| 868 | Task { await viewModel.unassignFromMe(ticket) } | ||
| 869 | } label: { | ||
| 870 | Label("Unassign Me", systemImage: "person.badge.minus") | ||
| 871 | } | ||
| 872 | .tint(.orange) | ||
| 873 | } | ||
| 874 | } | ||
| 875 | } | 531 | } |
| 876 | 532 | ||
| 877 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | 533 | Text(item.title) |
| 878 | HomeSectionMessageRow( | 534 | .font(.subheadline.weight(.semibold)) |
| 879 | text: "No open tickets assigned to you.", | 535 | .lineLimit(2) |
| 880 | systemImage: "person.crop.circle.badge.checkmark" | 536 | |
| 881 | ) | 537 | Spacer(minLength: 0) |
| 882 | } | ||
| 883 | } | ||
| 884 | .navigationTitle("Assigned Tickets") | ||
| 885 | .navigationBarTitleDisplayMode(.inline) | ||
| 886 | .refreshable { | ||
| 887 | await viewModel.loadDashboard() | ||
| 888 | } | ||
| 889 | .overlay { | ||
| 890 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | ||
| 891 | SRHTLoadingStateView(message: "Loading assigned tickets…") | ||
| 892 | } | ||
| 893 | } | 538 | } |
| 539 | .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) | ||
| 540 | .padding(10) | ||
| 541 | .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) | ||
| 894 | } | 542 | } |
| 895 | } | 543 | } |
| 896 | 544 | ||
| 897 | private struct HomeSectionMessageRow: View { | 545 | private struct HomeCompactMessageRow: View { |
| 898 | let text: String | 546 | let text: String |
| 899 | let systemImage: String | 547 | let systemImage: String |
| 900 | var emphasized = false | ||
| 901 | var accessibilityHint: String? = nil | ||
| 902 | 548 | ||
| 903 | var body: some View { | 549 | var body: some View { |
| 904 | Label(text, systemImage: systemImage) | 550 | Label(text, systemImage: systemImage) |
| 905 | .font(.subheadline) | 551 | .font(.caption) |
| 906 | .foregroundStyle(emphasized ? .secondary : .tertiary) | 552 | .foregroundStyle(.secondary) |
| 907 | .accessibilityHint(accessibilityHint ?? "") | 553 | .padding(.vertical, 2) |
| 908 | } | 554 | } |
| 909 | } | 555 | } |
Hutch/Views/Home/HomeViewModel.swift +3 −3
| @@ -393,7 +393,7 @@ final class HomeViewModel { | |||
| 393 | 393 | ||
| 394 | let inboxUnreadSnapshot = await inboxUnreadTask | 394 | let inboxUnreadSnapshot = await inboxUnreadTask |
| 395 | unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount | 395 | unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount |
| 396 | unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) | 396 | unreadInboxThreads = inboxUnreadSnapshot?.threads ?? [] |
| 397 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 | 397 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 |
| 398 | let systemStatusResult = await systemStatusTask | 398 | let systemStatusResult = await systemStatusTask |
| 399 | switch systemStatusResult { | 399 | switch systemStatusResult { |
| @@ -414,7 +414,7 @@ final class HomeViewModel { | |||
| 414 | } | 414 | } |
| 415 | 415 | ||
| 416 | var pinnedProjects: [Project] { | 416 | var pinnedProjects: [Project] { |
| 417 | let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults) | 417 | let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults) |
| 418 | guard !pinnedIDs.isEmpty else { return [] } | 418 | guard !pinnedIDs.isEmpty else { return [] } |
| 419 | 419 | ||
| 420 | let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) | 420 | let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) }) |
| @@ -422,7 +422,7 @@ final class HomeViewModel { | |||
| 422 | } | 422 | } |
| 423 | 423 | ||
| 424 | var hasPinnedProjects: Bool { | 424 | var hasPinnedProjects: Bool { |
| 425 | !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty | 425 | !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty |
| 426 | } | 426 | } |
| 427 | 427 | ||
| 428 | var failedBuildCount: Int { | 428 | var failedBuildCount: Int { |
Hutch/Views/Home/RecentActivityStore.swift added +148
| @@ -0,0 +1,148 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum RecentActivityKind: String, Codable, Sendable { | ||
| 4 | case repository | ||
| 5 | case ticket | ||
| 6 | case build | ||
| 7 | } | ||
| 8 | |||
| 9 | struct RecentActivityEntry: Codable, Hashable, Identifiable, Sendable { | ||
| 10 | let kind: RecentActivityKind | ||
| 11 | let title: String | ||
| 12 | let viewedAt: Date | ||
| 13 | let repositoryOwner: String? | ||
| 14 | let repositoryName: String? | ||
| 15 | let repositoryService: SRHTService? | ||
| 16 | let ticketOwnerUsername: String? | ||
| 17 | let ticketTrackerName: String? | ||
| 18 | let ticketId: Int? | ||
| 19 | let buildJobId: Int? | ||
| 20 | |||
| 21 | var id: String { | ||
| 22 | switch kind { | ||
| 23 | case .repository: | ||
| 24 | let service = repositoryService?.rawValue ?? SRHTService.git.rawValue | ||
| 25 | return "repo:\(service):\(repositoryOwner ?? "")/\(repositoryName ?? "")" | ||
| 26 | case .ticket: | ||
| 27 | return "ticket:\(ticketOwnerUsername ?? "")/\(ticketTrackerName ?? "")#\(ticketId ?? 0)" | ||
| 28 | case .build: | ||
| 29 | return "build:\(buildJobId ?? 0)" | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | var detailText: String { | ||
| 34 | switch kind { | ||
| 35 | case .repository: | ||
| 36 | let serviceName = repositoryService?.displayName ?? "Repository" | ||
| 37 | return "\(serviceName) • Viewed \(viewedAt.relativeDescription)" | ||
| 38 | case .ticket: | ||
| 39 | return "Ticket • Viewed \(viewedAt.relativeDescription)" | ||
| 40 | case .build: | ||
| 41 | return "Build • Viewed \(viewedAt.relativeDescription)" | ||
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | enum RecentActivityStore { | ||
| 47 | private static let maximumEntries = 5 | ||
| 48 | |||
| 49 | static func load(defaults: UserDefaults) -> [RecentActivityEntry] { | ||
| 50 | guard let data = defaults.data(forKey: AppStorageKeys.recentActivity) else { | ||
| 51 | return [] | ||
| 52 | } | ||
| 53 | |||
| 54 | do { | ||
| 55 | return try JSONDecoder().decode([RecentActivityEntry].self, from: data) | ||
| 56 | } catch { | ||
| 57 | defaults.removeObject(forKey: AppStorageKeys.recentActivity) | ||
| 58 | return [] | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | static func recordRepository( | ||
| 63 | _ repository: RepositorySummary, | ||
| 64 | defaults: UserDefaults, | ||
| 65 | now: Date = .now | ||
| 66 | ) { | ||
| 67 | record( | ||
| 68 | RecentActivityEntry( | ||
| 69 | kind: .repository, | ||
| 70 | title: "\(repository.owner.canonicalName)/\(repository.name)", | ||
| 71 | viewedAt: now, | ||
| 72 | repositoryOwner: repository.owner.canonicalName.srhtUsername, | ||
| 73 | repositoryName: repository.name, | ||
| 74 | repositoryService: repository.service, | ||
| 75 | ticketOwnerUsername: nil, | ||
| 76 | ticketTrackerName: nil, | ||
| 77 | ticketId: nil, | ||
| 78 | buildJobId: nil | ||
| 79 | ), | ||
| 80 | defaults: defaults | ||
| 81 | ) | ||
| 82 | } | ||
| 83 | |||
| 84 | static func recordTicket( | ||
| 85 | ownerUsername: String, | ||
| 86 | trackerName: String, | ||
| 87 | ticketId: Int, | ||
| 88 | title: String, | ||
| 89 | defaults: UserDefaults, | ||
| 90 | now: Date = .now | ||
| 91 | ) { | ||
| 92 | record( | ||
| 93 | RecentActivityEntry( | ||
| 94 | kind: .ticket, | ||
| 95 | title: "#\(ticketId) \(title)", | ||
| 96 | viewedAt: now, | ||
| 97 | repositoryOwner: nil, | ||
| 98 | repositoryName: nil, | ||
| 99 | repositoryService: nil, | ||
| 100 | ticketOwnerUsername: ownerUsername, | ||
| 101 | ticketTrackerName: trackerName, | ||
| 102 | ticketId: ticketId, | ||
| 103 | buildJobId: nil | ||
| 104 | ), | ||
| 105 | defaults: defaults | ||
| 106 | ) | ||
| 107 | } | ||
| 108 | |||
| 109 | static func recordBuild( | ||
| 110 | jobId: Int, | ||
| 111 | title: String, | ||
| 112 | defaults: UserDefaults, | ||
| 113 | now: Date = .now | ||
| 114 | ) { | ||
| 115 | record( | ||
| 116 | RecentActivityEntry( | ||
| 117 | kind: .build, | ||
| 118 | title: title, | ||
| 119 | viewedAt: now, | ||
| 120 | repositoryOwner: nil, | ||
| 121 | repositoryName: nil, | ||
| 122 | repositoryService: nil, | ||
| 123 | ticketOwnerUsername: nil, | ||
| 124 | ticketTrackerName: nil, | ||
| 125 | ticketId: nil, | ||
| 126 | buildJobId: jobId | ||
| 127 | ), | ||
| 128 | defaults: defaults | ||
| 129 | ) | ||
| 130 | } | ||
| 131 | |||
| 132 | private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) { | ||
| 133 | var entries = load(defaults: defaults) | ||
| 134 | entries.removeAll { $0.id == entry.id } | ||
| 135 | entries.insert(entry, at: 0) | ||
| 136 | |||
| 137 | if entries.count > maximumEntries { | ||
| 138 | entries = Array(entries.prefix(maximumEntries)) | ||
| 139 | } | ||
| 140 | |||
| 141 | save(entries, defaults: defaults) | ||
| 142 | } | ||
| 143 | |||
| 144 | private static func save(_ entries: [RecentActivityEntry], defaults: UserDefaults) { | ||
| 145 | guard let data = try? JSONEncoder().encode(entries) else { return } | ||
| 146 | defaults.set(data, forKey: AppStorageKeys.recentActivity) | ||
| 147 | } | ||
| 148 | } | ||
Hutch/Views/Inbox/InboxView.swift +3 −189
| @@ -1,196 +1,10 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | // Legacy wrapper kept temporarily so stale references continue to compile while | ||
| 4 | // the app transitions from Inbox to Work. | ||
| 3 | struct InboxView: View { | 5 | struct InboxView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @Environment(\.scenePhase) private var scenePhase | ||
| 6 | @State private var viewModel: InboxViewModel? | ||
| 7 | @State private var selectedThreadID: InboxThreadSummary.ID? | ||
| 8 | @State private var selectedThreadSnapshot: InboxThreadSummary? | ||
| 9 | @State private var isShowingThreadDetail = false | ||
| 10 | |||
| 11 | var body: some View { | 6 | var body: some View { |
| 12 | Group { | 7 | WorkView() |
| 13 | if let viewModel { | ||
| 14 | listContent(viewModel) | ||
| 15 | } else { | ||
| 16 | SRHTLoadingStateView(message: "Loading inbox…") | ||
| 17 | } | ||
| 18 | } | ||
| 19 | .navigationTitle("Inbox") | ||
| 20 | .task { | ||
| 21 | let vm: InboxViewModel | ||
| 22 | if let viewModel { | ||
| 23 | vm = viewModel | ||
| 24 | } else { | ||
| 25 | let newViewModel = InboxViewModel( | ||
| 26 | client: appState.client, | ||
| 27 | defaults: appState.accountDefaults, | ||
| 28 | accountID: appState.activeAccountID | ||
| 29 | ) | ||
| 30 | viewModel = newViewModel | ||
| 31 | vm = newViewModel | ||
| 32 | } | ||
| 33 | |||
| 34 | await vm.loadThreads() | ||
| 35 | } | ||
| 36 | .onChange(of: scenePhase) { _, newPhase in | ||
| 37 | guard newPhase == .active, let viewModel, !isShowingThreadDetail else { return } | ||
| 38 | Task { | ||
| 39 | await viewModel.loadThreads() | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | @ViewBuilder | ||
| 45 | private func listContent(_ viewModel: InboxViewModel) -> some View { | ||
| 46 | @Bindable var vm = viewModel | ||
| 47 | |||
| 48 | List { | ||
| 49 | Section { | ||
| 50 | Picker("Filter", selection: $vm.filter) { | ||
| 51 | ForEach(InboxThreadFilter.allCases, id: \.self) { filter in | ||
| 52 | Text(filter.rawValue).tag(filter) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | .pickerStyle(.segmented) | ||
| 56 | .listRowBackground(Color.clear) | ||
| 57 | .listRowInsets(EdgeInsets()) | ||
| 58 | } | ||
| 59 | |||
| 60 | ForEach(viewModel.filteredThreads) { thread in | ||
| 61 | Button { | ||
| 62 | selectThread(thread) | ||
| 63 | } label: { | ||
| 64 | InboxThreadRow(thread: thread) | ||
| 65 | } | ||
| 66 | .buttonStyle(.plain) | ||
| 67 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 68 | readStateAction(for: thread, in: viewModel) | ||
| 69 | } | ||
| 70 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 71 | readStateAction(for: thread, in: viewModel) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | .themedList() | ||
| 76 | .searchable( | ||
| 77 | text: $vm.searchText, | ||
| 78 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 79 | prompt: "Search inbox" | ||
| 80 | ) | ||
| 81 | .listStyle(.plain) | ||
| 82 | .toolbar { | ||
| 83 | ToolbarItem(placement: .topBarTrailing) { | ||
| 84 | if viewModel.hasUnreadThreads { | ||
| 85 | Button("Mark All Read") { | ||
| 86 | withAnimation(.easeInOut(duration: 0.2)) { | ||
| 87 | viewModel.markAllThreadsRead() | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | .overlay { | ||
| 94 | if viewModel.isLoading, viewModel.threads.isEmpty { | ||
| 95 | SRHTLoadingStateView(message: "Loading inbox…") | ||
| 96 | } else if let error = viewModel.error, viewModel.threads.isEmpty { | ||
| 97 | SRHTErrorStateView( | ||
| 98 | title: "Failed to load inbox", | ||
| 99 | message: error, | ||
| 100 | retryAction: { await viewModel.loadThreads() } | ||
| 101 | ) | ||
| 102 | } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { | ||
| 103 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 104 | } else if viewModel.threads.isEmpty, viewModel.error == nil { | ||
| 105 | ContentUnavailableView( | ||
| 106 | "Inbox Zero", | ||
| 107 | systemImage: "tray", | ||
| 108 | description: Text("You're up to date.") | ||
| 109 | ) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) { | ||
| 113 | await viewModel.loadThreads() | ||
| 114 | } | ||
| 115 | .srhtErrorBanner(error: $vm.error) | ||
| 116 | .refreshable { | ||
| 117 | await viewModel.loadThreads() | ||
| 118 | } | ||
| 119 | .onChange(of: viewModel.threads) { _, threads in | ||
| 120 | syncSelectedThreadSnapshot(with: threads) | ||
| 121 | } | ||
| 122 | .navigationDestination(isPresented: Binding( | ||
| 123 | get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil }, | ||
| 124 | set: { isPresented in | ||
| 125 | if !isPresented { | ||
| 126 | clearSelection() | ||
| 127 | } | ||
| 128 | isShowingThreadDetail = isPresented | ||
| 129 | } | ||
| 130 | )) { | ||
| 131 | if let thread = selectedThread(for: viewModel) { | ||
| 132 | ThreadDetailView(thread: thread) { | ||
| 133 | viewModel.markThreadRead(thread) | ||
| 134 | } | ||
| 135 | .onAppear { | ||
| 136 | cacheSelectedThread(thread) | ||
| 137 | } | ||
| 138 | .onDisappear { | ||
| 139 | handleThreadDetailDisappear(for: thread.id) | ||
| 140 | } | ||
| 141 | } else { | ||
| 142 | ContentUnavailableView( | ||
| 143 | "Thread Unavailable", | ||
| 144 | systemImage: "tray", | ||
| 145 | description: Text("This thread could not be restored.") | ||
| 146 | ) | ||
| 147 | } | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | @ViewBuilder | ||
| 152 | private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { | ||
| 153 | Button { | ||
| 154 | withAnimation(.easeInOut(duration: 0.2)) { | ||
| 155 | viewModel.markThreadRead(thread) | ||
| 156 | } | ||
| 157 | } label: { | ||
| 158 | Label("Mark as Read", systemImage: "envelope.open") | ||
| 159 | } | ||
| 160 | .tint(.blue) | ||
| 161 | } | ||
| 162 | |||
| 163 | private func selectThread(_ thread: InboxThreadSummary) { | ||
| 164 | cacheSelectedThread(thread) | ||
| 165 | isShowingThreadDetail = true | ||
| 166 | } | ||
| 167 | |||
| 168 | private func cacheSelectedThread(_ thread: InboxThreadSummary) { | ||
| 169 | selectedThreadID = thread.id | ||
| 170 | selectedThreadSnapshot = thread | ||
| 171 | } | ||
| 172 | |||
| 173 | private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? { | ||
| 174 | guard let selectedThreadID else { return selectedThreadSnapshot } | ||
| 175 | return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil) | ||
| 176 | } | ||
| 177 | |||
| 178 | private func handleThreadDetailDisappear(for threadID: String) { | ||
| 179 | let isActiveSelection = selectedThreadID == threadID | ||
| 180 | guard isActiveSelection else { return } | ||
| 181 | clearSelection() | ||
| 182 | } | ||
| 183 | |||
| 184 | private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) { | ||
| 185 | guard let selectedThreadID else { return } | ||
| 186 | guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return } | ||
| 187 | selectedThreadSnapshot = updatedThread | ||
| 188 | } | ||
| 189 | |||
| 190 | private func clearSelection() { | ||
| 191 | selectedThreadID = nil | ||
| 192 | selectedThreadSnapshot = nil | ||
| 193 | isShowingThreadDetail = false | ||
| 194 | } | 8 | } |
| 195 | } | 9 | } |
| 196 | 10 | ||
Hutch/Views/Lookup/UserProfileView.swift +29
| @@ -6,6 +6,7 @@ struct UserProfileView: View { | |||
| 6 | 6 | ||
| 7 | let user: User | 7 | let user: User |
| 8 | @State private var profileViewModel: UserProfileViewModel? | 8 | @State private var profileViewModel: UserProfileViewModel? |
| 9 | @State private var pinChangeCount = 0 | ||
| 9 | 10 | ||
| 10 | private static let iso8601Formatter: ISO8601DateFormatter = { | 11 | private static let iso8601Formatter: ISO8601DateFormatter = { |
| 11 | let formatter = ISO8601DateFormatter() | 12 | let formatter = ISO8601DateFormatter() |
| @@ -14,6 +15,16 @@ struct UserProfileView: View { | |||
| 14 | return formatter | 15 | return formatter |
| 15 | }() | 16 | }() |
| 16 | 17 | ||
| 18 | private var currentUserKey: String? { | ||
| 19 | appState.currentUser?.canonicalName | ||
| 20 | } | ||
| 21 | |||
| 22 | private var isPinnedToHome: Bool { | ||
| 23 | _ = pinChangeCount | ||
| 24 | guard let currentUserKey else { return false } | ||
| 25 | return HomePinStore.isPinned(.user(user), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 26 | } | ||
| 27 | |||
| 17 | var body: some View { | 28 | var body: some View { |
| 18 | List { | 29 | List { |
| 19 | if let avatarURL = user.avatar.flatMap(URL.init(string:)) { | 30 | if let avatarURL = user.avatar.flatMap(URL.init(string:)) { |
| @@ -164,6 +175,18 @@ struct UserProfileView: View { | |||
| 164 | .listStyle(.insetGrouped) | 175 | .listStyle(.insetGrouped) |
| 165 | .navigationTitle(user.canonicalName) | 176 | .navigationTitle(user.canonicalName) |
| 166 | .navigationBarTitleDisplayMode(.inline) | 177 | .navigationBarTitleDisplayMode(.inline) |
| 178 | .toolbar { | ||
| 179 | if currentUserKey != nil { | ||
| 180 | ToolbarItem(placement: .topBarTrailing) { | ||
| 181 | Button { | ||
| 182 | togglePinnedState() | ||
| 183 | } label: { | ||
| 184 | Image(systemName: isPinnedToHome ? "pin.fill" : "pin") | ||
| 185 | } | ||
| 186 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") | ||
| 187 | } | ||
| 188 | } | ||
| 189 | } | ||
| 167 | .task(id: user.canonicalName) { | 190 | .task(id: user.canonicalName) { |
| 168 | let owner = user.canonicalName.hasPrefix("~") | 191 | let owner = user.canonicalName.hasPrefix("~") |
| 169 | ? String(user.canonicalName.dropFirst()) | 192 | ? String(user.canonicalName.dropFirst()) |
| @@ -200,6 +223,12 @@ struct UserProfileView: View { | |||
| 200 | } | 223 | } |
| 201 | } | 224 | } |
| 202 | 225 | ||
| 226 | private func togglePinnedState() { | ||
| 227 | guard let currentUserKey else { return } | ||
| 228 | HomePinStore.togglePin(.user(user), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 229 | pinChangeCount += 1 | ||
| 230 | } | ||
| 231 | |||
| 203 | private func formattedTimestamp(_ value: String) -> String { | 232 | private func formattedTimestamp(_ value: String) -> String { |
| 204 | guard let date = Self.iso8601Formatter.date(from: value) else { | 233 | guard let date = Self.iso8601Formatter.date(from: value) else { |
| 205 | return value | 234 | return value |
Hutch/Views/Projects/ProjectMailingListView.swift +29
| @@ -229,6 +229,17 @@ struct MailingListDetailView: View { | |||
| 229 | 229 | ||
| 230 | @Environment(AppState.self) private var appState | 230 | @Environment(AppState.self) private var appState |
| 231 | @State private var viewModel: MailingListDetailViewModel? | 231 | @State private var viewModel: MailingListDetailViewModel? |
| 232 | @State private var pinChangeCount = 0 | ||
| 233 | |||
| 234 | private var currentUserKey: String? { | ||
| 235 | appState.currentUser?.canonicalName | ||
| 236 | } | ||
| 237 | |||
| 238 | private var isPinnedToHome: Bool { | ||
| 239 | _ = pinChangeCount | ||
| 240 | guard let currentUserKey else { return false } | ||
| 241 | return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 242 | } | ||
| 232 | 243 | ||
| 233 | var body: some View { | 244 | var body: some View { |
| 234 | Group { | 245 | Group { |
| @@ -240,6 +251,18 @@ struct MailingListDetailView: View { | |||
| 240 | } | 251 | } |
| 241 | .navigationTitle(mailingList.name) | 252 | .navigationTitle(mailingList.name) |
| 242 | .navigationBarTitleDisplayMode(.inline) | 253 | .navigationBarTitleDisplayMode(.inline) |
| 254 | .toolbar { | ||
| 255 | if currentUserKey != nil { | ||
| 256 | ToolbarItem(placement: .topBarTrailing) { | ||
| 257 | Button { | ||
| 258 | togglePinnedState() | ||
| 259 | } label: { | ||
| 260 | Image(systemName: isPinnedToHome ? "pin.fill" : "pin") | ||
| 261 | } | ||
| 262 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") | ||
| 263 | } | ||
| 264 | } | ||
| 265 | } | ||
| 243 | .task { | 266 | .task { |
| 244 | if viewModel == nil { | 267 | if viewModel == nil { |
| 245 | let viewModel = MailingListDetailViewModel( | 268 | let viewModel = MailingListDetailViewModel( |
| @@ -260,6 +283,12 @@ struct MailingListDetailView: View { | |||
| 260 | } | 283 | } |
| 261 | } | 284 | } |
| 262 | 285 | ||
| 286 | private func togglePinnedState() { | ||
| 287 | guard let currentUserKey else { return } | ||
| 288 | HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 289 | pinChangeCount += 1 | ||
| 290 | } | ||
| 291 | |||
| 263 | @ViewBuilder | 292 | @ViewBuilder |
| 264 | private func content(_ viewModel: MailingListDetailViewModel) -> some View { | 293 | private func content(_ viewModel: MailingListDetailViewModel) -> some View { |
| 265 | @Bindable var vm = viewModel | 294 | @Bindable var vm = viewModel |
Hutch/Views/Projects/ProjectPinStore.swift +29
| @@ -5,6 +5,11 @@ enum ProjectPinStore { | |||
| 5 | for userKey: String, | 5 | for userKey: String, |
| 6 | defaults: UserDefaults = .standard | 6 | defaults: UserDefaults = .standard |
| 7 | ) -> [String] { | 7 | ) -> [String] { |
| 8 | let generalizedPins = HomePinStore.pinnedProjectIDs(for: userKey, defaults: defaults) | ||
| 9 | if !generalizedPins.isEmpty { | ||
| 10 | return generalizedPins | ||
| 11 | } | ||
| 12 | |||
| 8 | let pinnedProjects = loadAll(defaults: defaults) | 13 | let pinnedProjects = loadAll(defaults: defaults) |
| 9 | return normalizedProjectIDs(pinnedProjects[userKey] ?? []) | 14 | return normalizedProjectIDs(pinnedProjects[userKey] ?? []) |
| 10 | } | 15 | } |
| @@ -22,6 +27,22 @@ enum ProjectPinStore { | |||
| 22 | for userKey: String, | 27 | for userKey: String, |
| 23 | defaults: UserDefaults = .standard | 28 | defaults: UserDefaults = .standard |
| 24 | ) { | 29 | ) { |
| 30 | let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 31 | guard !trimmed.isEmpty else { return } | ||
| 32 | |||
| 33 | HomePinStore.togglePin( | ||
| 34 | HomePinRecord( | ||
| 35 | kind: .project, | ||
| 36 | value: trimmed, | ||
| 37 | title: "Pinned Project", | ||
| 38 | subtitle: "Project", | ||
| 39 | ownerUsername: nil, | ||
| 40 | service: nil | ||
| 41 | ), | ||
| 42 | for: userKey, | ||
| 43 | defaults: defaults | ||
| 44 | ) | ||
| 45 | |||
| 25 | var pinnedProjects = loadAll(defaults: defaults) | 46 | var pinnedProjects = loadAll(defaults: defaults) |
| 26 | var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? []) | 47 | var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? []) |
| 27 | 48 | ||
| @@ -37,6 +58,14 @@ enum ProjectPinStore { | |||
| 37 | save(pinnedProjects, defaults: defaults) | 58 | save(pinnedProjects, defaults: defaults) |
| 38 | } | 59 | } |
| 39 | 60 | ||
| 61 | static func loadLegacyPinnedProjectIDs( | ||
| 62 | for userKey: String, | ||
| 63 | defaults: UserDefaults = .standard | ||
| 64 | ) -> [String] { | ||
| 65 | let pinnedProjects = loadAll(defaults: defaults) | ||
| 66 | return normalizedProjectIDs(pinnedProjects[userKey] ?? []) | ||
| 67 | } | ||
| 68 | |||
| 40 | private static func loadAll(defaults: UserDefaults) -> [String: [String]] { | 69 | private static func loadAll(defaults: UserDefaults) -> [String: [String]] { |
| 41 | guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { | 70 | guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else { |
| 42 | return [:] | 71 | return [:] |
Hutch/Views/Repositories/HgRepositoryDetailView.swift +52 −11
| @@ -19,12 +19,23 @@ struct HgRepositoryDetailView: View { | |||
| 19 | @State private var showShareUnavailableAlert = false | 19 | @State private var showShareUnavailableAlert = false |
| 20 | @State private var didCopyFileContents = false | 20 | @State private var didCopyFileContents = false |
| 21 | @State private var copyResetTask: Task<Void, Never>? | 21 | @State private var copyResetTask: Task<Void, Never>? |
| 22 | @State private var pinChangeCount = 0 | ||
| 22 | 23 | ||
| 23 | private var canManageRepository: Bool { | 24 | private var canManageRepository: Bool { |
| 24 | guard let currentUser = appState.currentUser else { return false } | 25 | guard let currentUser = appState.currentUser else { return false } |
| 25 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | 26 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 29 | private var currentUserKey: String? { | ||
| 30 | appState.currentUser?.canonicalName | ||
| 31 | } | ||
| 32 | |||
| 33 | private var isPinnedToHome: Bool { | ||
| 34 | _ = pinChangeCount | ||
| 35 | guard let currentUserKey else { return false } | ||
| 36 | return HomePinStore.isPinned(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 37 | } | ||
| 38 | |||
| 28 | private var shareURL: URL? { | 39 | private var shareURL: URL? { |
| 29 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } | 40 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } |
| 30 | return SRHTWebURL.file( | 41 | return SRHTWebURL.file( |
| @@ -58,17 +69,7 @@ struct HgRepositoryDetailView: View { | |||
| 58 | } | 69 | } |
| 59 | } | 70 | } |
| 60 | 71 | ||
| 61 | SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { | 72 | repositoryActionsMenu |
| 62 | Image(systemName: "square.and.arrow.up") | ||
| 63 | } | ||
| 64 | |||
| 65 | if canManageRepository { | ||
| 66 | Button { | ||
| 67 | showSettings = true | ||
| 68 | } label: { | ||
| 69 | Image(systemName: "gear") | ||
| 70 | } | ||
| 71 | } | ||
| 72 | } | 73 | } |
| 73 | } | 74 | } |
| 74 | .sheet(isPresented: $showSettings) { | 75 | .sheet(isPresented: $showSettings) { |
| @@ -106,6 +107,46 @@ struct HgRepositoryDetailView: View { | |||
| 106 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | 107 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 110 | private func togglePinnedState() { | ||
| 111 | guard let currentUserKey else { return } | ||
| 112 | HomePinStore.togglePin(.repository(repository), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 113 | pinChangeCount += 1 | ||
| 114 | } | ||
| 115 | |||
| 116 | private var repositoryActionsMenu: some View { | ||
| 117 | Menu { | ||
| 118 | if currentUserKey != nil { | ||
| 119 | Button { | ||
| 120 | togglePinnedState() | ||
| 121 | } label: { | ||
| 122 | Label( | ||
| 123 | isPinnedToHome ? "Unpin from Home" : "Pin to Home", | ||
| 124 | systemImage: isPinnedToHome ? "pin.slash" : "pin" | ||
| 125 | ) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | if let shareURL = SRHTWebURL.repository(repository) { | ||
| 130 | ShareLink(item: shareURL) { | ||
| 131 | Label("Share", systemImage: "square.and.arrow.up") | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | if canManageRepository { | ||
| 136 | Divider() | ||
| 137 | |||
| 138 | Button { | ||
| 139 | showSettings = true | ||
| 140 | } label: { | ||
| 141 | Label("Repository Settings", systemImage: "gear") | ||
| 142 | } | ||
| 143 | } | ||
| 144 | } label: { | ||
| 145 | Image(systemName: "ellipsis.circle") | ||
| 146 | } | ||
| 147 | .accessibilityLabel("Repository actions") | ||
| 148 | } | ||
| 149 | |||
| 109 | @ViewBuilder | 150 | @ViewBuilder |
| 110 | private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { | 151 | private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 111 | VStack(spacing: 0) { | 152 | VStack(spacing: 0) { |
Hutch/Views/Repositories/RepositoryDetailView.swift +38 −5
| @@ -13,12 +13,23 @@ struct RepositoryDetailView: View { | |||
| 13 | @State private var showSettings = false | 13 | @State private var showSettings = false |
| 14 | @State private var showACLs = false | 14 | @State private var showACLs = false |
| 15 | @State private var currentRepository: RepositorySummary | 15 | @State private var currentRepository: RepositorySummary |
| 16 | @State private var pinChangeCount = 0 | ||
| 16 | 17 | ||
| 17 | private var canManageRepository: Bool { | 18 | private var canManageRepository: Bool { |
| 18 | guard let currentUser = appState.currentUser else { return false } | 19 | guard let currentUser = appState.currentUser else { return false } |
| 19 | return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) | 20 | return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName) |
| 20 | } | 21 | } |
| 21 | 22 | ||
| 23 | private var currentUserKey: String? { | ||
| 24 | appState.currentUser?.canonicalName | ||
| 25 | } | ||
| 26 | |||
| 27 | private var isPinnedToHome: Bool { | ||
| 28 | _ = pinChangeCount | ||
| 29 | guard let currentUserKey else { return false } | ||
| 30 | return HomePinStore.isPinned(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 31 | } | ||
| 32 | |||
| 22 | init( | 33 | init( |
| 23 | repository: RepositorySummary, | 34 | repository: RepositorySummary, |
| 24 | onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, | 35 | onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil, |
| @@ -43,12 +54,8 @@ struct RepositoryDetailView: View { | |||
| 43 | .navigationTitle(currentRepository.name) | 54 | .navigationTitle(currentRepository.name) |
| 44 | .navigationBarTitleDisplayMode(.inline) | 55 | .navigationBarTitleDisplayMode(.inline) |
| 45 | .toolbar { | 56 | .toolbar { |
| 46 | ToolbarItemGroup(placement: .topBarTrailing) { | 57 | ToolbarItem(placement: .topBarTrailing) { |
| 47 | repositoryActionsMenu | 58 | repositoryActionsMenu |
| 48 | |||
| 49 | SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { | ||
| 50 | Image(systemName: "square.and.arrow.up") | ||
| 51 | } | ||
| 52 | } | 59 | } |
| 53 | } | 60 | } |
| 54 | .sheet(isPresented: $showSettings) { | 61 | .sheet(isPresented: $showSettings) { |
| @@ -82,6 +89,7 @@ struct RepositoryDetailView: View { | |||
| 82 | client: appState.client | 89 | client: appState.client |
| 83 | ) | 90 | ) |
| 84 | } | 91 | } |
| 92 | RecentActivityStore.recordRepository(currentRepository, defaults: appState.accountDefaults) | ||
| 85 | } | 93 | } |
| 86 | } | 94 | } |
| 87 | } | 95 | } |
| @@ -130,6 +138,25 @@ struct RepositoryDetailView: View { | |||
| 130 | 138 | ||
| 131 | private var repositoryActionsMenu: some View { | 139 | private var repositoryActionsMenu: some View { |
| 132 | Menu { | 140 | Menu { |
| 141 | if currentUserKey != nil { | ||
| 142 | Button { | ||
| 143 | togglePinnedState() | ||
| 144 | } label: { | ||
| 145 | Label( | ||
| 146 | isPinnedToHome ? "Unpin from Home" : "Pin to Home", | ||
| 147 | systemImage: isPinnedToHome ? "pin.slash" : "pin" | ||
| 148 | ) | ||
| 149 | } | ||
| 150 | } | ||
| 151 | |||
| 152 | if let shareURL = SRHTWebURL.repository(currentRepository) { | ||
| 153 | ShareLink(item: shareURL) { | ||
| 154 | Label("Share", systemImage: "square.and.arrow.up") | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | Divider() | ||
| 159 | |||
| 133 | if let repositoryURL = SRHTWebURL.repository(currentRepository) { | 160 | if let repositoryURL = SRHTWebURL.repository(currentRepository) { |
| 134 | Button { | 161 | Button { |
| 135 | openURL(repositoryURL) | 162 | openURL(repositoryURL) |
| @@ -184,4 +211,10 @@ struct RepositoryDetailView: View { | |||
| 184 | } | 211 | } |
| 185 | .accessibilityLabel("Repository actions") | 212 | .accessibilityLabel("Repository actions") |
| 186 | } | 213 | } |
| 214 | |||
| 215 | private func togglePinnedState() { | ||
| 216 | guard let currentUserKey else { return } | ||
| 217 | HomePinStore.togglePin(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 218 | pinChangeCount += 1 | ||
| 219 | } | ||
| 187 | } | 220 | } |
Hutch/Views/Settings/SettingsView.swift +13 −1
| @@ -264,10 +264,22 @@ private struct AboutView: View { | |||
| 264 | get: { appState.isDebugModeEnabled }, | 264 | get: { appState.isDebugModeEnabled }, |
| 265 | set: { appState.isDebugModeEnabled = $0 } | 265 | set: { appState.isDebugModeEnabled = $0 } |
| 266 | )) | 266 | )) |
| 267 | |||
| 268 | NavigationLink { | ||
| 269 | HomePrototypeView() | ||
| 270 | } label: { | ||
| 271 | SwiftUI.Label("Home Prototype", systemImage: "house") | ||
| 272 | } | ||
| 273 | |||
| 274 | NavigationLink { | ||
| 275 | WorkPrototypeView() | ||
| 276 | } label: { | ||
| 277 | SwiftUI.Label("Work Prototype", systemImage: "tray.full") | ||
| 278 | } | ||
| 267 | } header: { | 279 | } header: { |
| 268 | Text("Developer") | 280 | Text("Developer") |
| 269 | } footer: { | 281 | } footer: { |
| 270 | Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.") | 282 | Text("Shows raw API payloads and diagnostic details on builds and tickets screens. Home Prototype explores the dashboard structure, while Work Prototype evaluates the personal queue surface. This stays hidden until explicitly enabled.") |
| 271 | } | 283 | } |
| 272 | } | 284 | } |
| 273 | } | 285 | } |
Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift +4 −6
| @@ -63,7 +63,8 @@ struct SystemStatusSummaryRow: View { | |||
| 63 | 63 | ||
| 64 | private var primaryMessage: String { | 64 | private var primaryMessage: String { |
| 65 | if let snapshot { | 65 | if let snapshot { |
| 66 | return snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText | 66 | let summary = snapshot.hasDisruption ? snapshot.bannerSummary : snapshot.overallStatusText |
| 67 | return "\(summary) • Updated \(snapshot.lastUpdated.relativeDescription)" | ||
| 67 | } | 68 | } |
| 68 | if let errorMessage, !errorMessage.isEmpty { | 69 | if let errorMessage, !errorMessage.isEmpty { |
| 69 | return errorMessage | 70 | return errorMessage |
| @@ -75,11 +76,8 @@ struct SystemStatusSummaryRow: View { | |||
| 75 | } | 76 | } |
| 76 | 77 | ||
| 77 | private var metadataMessage: String? { | 78 | private var metadataMessage: String? { |
| 78 | if let snapshot { | 79 | if snapshot != nil, isShowingStaleData { |
| 79 | if isShowingStaleData { | 80 | return "Showing saved data" |
| 80 | return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data" | ||
| 81 | } | ||
| 82 | return "Updated \(snapshot.lastUpdated.relativeDescription)" | ||
| 83 | } | 81 | } |
| 84 | if errorMessage != nil { | 82 | if errorMessage != nil { |
| 85 | return "Open System Status to retry." | 83 | return "Open System Status to retry." |
Hutch/Views/Tickets/TicketDetailView.swift +9
| @@ -220,6 +220,15 @@ struct TicketDetailView: View { | |||
| 220 | commentInput(viewModel) | 220 | commentInput(viewModel) |
| 221 | } | 221 | } |
| 222 | } | 222 | } |
| 223 | .task(id: ticket.id) { | ||
| 224 | RecentActivityStore.recordTicket( | ||
| 225 | ownerUsername: ownerUsername, | ||
| 226 | trackerName: trackerName, | ||
| 227 | ticketId: ticket.id, | ||
| 228 | title: ticket.title, | ||
| 229 | defaults: appState.accountDefaults | ||
| 230 | ) | ||
| 231 | } | ||
| 223 | .srhtErrorBanner(error: $vm.error) | 232 | .srhtErrorBanner(error: $vm.error) |
| 224 | .refreshable { | 233 | .refreshable { |
| 225 | await reloadDetail(viewModel) | 234 | await reloadDetail(viewModel) |
Hutch/Views/Tickets/TicketListView.swift +49 −15
| @@ -23,12 +23,29 @@ struct TicketListView: View { | |||
| 23 | @State private var showBulkCloseSheet = false | 23 | @State private var showBulkCloseSheet = false |
| 24 | @State private var showBulkAssignSheet = false | 24 | @State private var showBulkAssignSheet = false |
| 25 | @State private var bulkActionResult: TicketBulkActionResult? | 25 | @State private var bulkActionResult: TicketBulkActionResult? |
| 26 | @State private var pinChangeCount = 0 | ||
| 26 | 27 | ||
| 27 | private var isOwnedByCurrentUser: Bool { | 28 | private var isOwnedByCurrentUser: Bool { |
| 28 | guard let currentUser = appState.currentUser else { return false } | 29 | guard let currentUser = appState.currentUser else { return false } |
| 29 | return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) | 30 | return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) |
| 30 | } | 31 | } |
| 31 | 32 | ||
| 33 | private var currentUserKey: String? { | ||
| 34 | appState.currentUser?.canonicalName | ||
| 35 | } | ||
| 36 | |||
| 37 | private var isPinnedToHome: Bool { | ||
| 38 | _ = pinChangeCount | ||
| 39 | guard let currentUserKey else { return false } | ||
| 40 | return HomePinStore.isPinned(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 41 | } | ||
| 42 | |||
| 43 | private var navigationTitle: String { | ||
| 44 | guard let viewModel, viewModel.isSelectionMode else { return tracker.name } | ||
| 45 | let count = viewModel.selectedTicketCount | ||
| 46 | return count == 0 ? "Select Tickets" : "\(count) Selected" | ||
| 47 | } | ||
| 48 | |||
| 32 | init( | 49 | init( |
| 33 | tracker: TrackerSummary, | 50 | tracker: TrackerSummary, |
| 34 | onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }, | 51 | onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ }, |
| @@ -47,7 +64,7 @@ struct TicketListView: View { | |||
| 47 | SRHTLoadingStateView(message: "Loading tickets…") | 64 | SRHTLoadingStateView(message: "Loading tickets…") |
| 48 | } | 65 | } |
| 49 | } | 66 | } |
| 50 | .navigationTitle(tracker.name) | 67 | .navigationTitle(navigationTitle) |
| 51 | .navigationBarTitleDisplayMode(.inline) | 68 | .navigationBarTitleDisplayMode(.inline) |
| 52 | .toolbar { | 69 | .toolbar { |
| 53 | ToolbarItem(placement: .topBarLeading) { | 70 | ToolbarItem(placement: .topBarLeading) { |
| @@ -66,16 +83,6 @@ struct TicketListView: View { | |||
| 66 | } | 83 | } |
| 67 | .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) | 84 | .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) |
| 68 | } else { | 85 | } else { |
| 69 | SRHTShareButton( | ||
| 70 | url: SRHTWebURL.tracker( | ||
| 71 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | ||
| 72 | trackerName: tracker.name | ||
| 73 | ), | ||
| 74 | target: .tracker | ||
| 75 | ) { | ||
| 76 | Image(systemName: "square.and.arrow.up") | ||
| 77 | } | ||
| 78 | |||
| 79 | Button { | 86 | Button { |
| 80 | showCreateTicketSheet = true | 87 | showCreateTicketSheet = true |
| 81 | } label: { | 88 | } label: { |
| @@ -83,10 +90,6 @@ struct TicketListView: View { | |||
| 83 | } | 90 | } |
| 84 | .accessibilityLabel("Create ticket") | 91 | .accessibilityLabel("Create ticket") |
| 85 | 92 | ||
| 86 | Button("Select") { | ||
| 87 | viewModel?.setSelectionMode(true) | ||
| 88 | } | ||
| 89 | |||
| 90 | trackerActionsMenu | 93 | trackerActionsMenu |
| 91 | } | 94 | } |
| 92 | } | 95 | } |
| @@ -242,6 +245,12 @@ struct TicketListView: View { | |||
| 242 | } | 245 | } |
| 243 | } | 246 | } |
| 244 | 247 | ||
| 248 | private func togglePinnedState() { | ||
| 249 | guard let currentUserKey else { return } | ||
| 250 | HomePinStore.togglePin(.tracker(tracker), for: currentUserKey, defaults: appState.accountDefaults) | ||
| 251 | pinChangeCount += 1 | ||
| 252 | } | ||
| 253 | |||
| 245 | @ViewBuilder | 254 | @ViewBuilder |
| 246 | private func listContent(_ viewModel: TicketListViewModel) -> some View { | 255 | private func listContent(_ viewModel: TicketListViewModel) -> some View { |
| 247 | @Bindable var vm = viewModel | 256 | @Bindable var vm = viewModel |
| @@ -429,6 +438,31 @@ struct TicketListView: View { | |||
| 429 | 438 | ||
| 430 | private var trackerActionsMenu: some View { | 439 | private var trackerActionsMenu: some View { |
| 431 | Menu { | 440 | Menu { |
| 441 | Button { | ||
| 442 | viewModel?.setSelectionMode(true) | ||
| 443 | } label: { | ||
| 444 | Label("Select", systemImage: "checkmark.circle") | ||
| 445 | } | ||
| 446 | |||
| 447 | if currentUserKey != nil { | ||
| 448 | Button { | ||
| 449 | togglePinnedState() | ||
| 450 | } label: { | ||
| 451 | Label( | ||
| 452 | isPinnedToHome ? "Unpin from Home" : "Pin to Home", | ||
| 453 | systemImage: isPinnedToHome ? "pin.slash" : "pin" | ||
| 454 | ) | ||
| 455 | } | ||
| 456 | } | ||
| 457 | |||
| 458 | if let shareURL = SRHTWebURL.tracker(tracker) { | ||
| 459 | ShareLink(item: shareURL) { | ||
| 460 | Label("Share", systemImage: "square.and.arrow.up") | ||
| 461 | } | ||
| 462 | } | ||
| 463 | |||
| 464 | Divider() | ||
| 465 | |||
| 432 | if let trackerURL = SRHTWebURL.tracker(tracker) { | 466 | if let trackerURL = SRHTWebURL.tracker(tracker) { |
| 433 | Button { | 467 | Button { |
| 434 | openURL(trackerURL) | 468 | openURL(trackerURL) |
Hutch/Views/Work/WorkPrototypeView.swift added +7
| @@ -0,0 +1,7 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct WorkPrototypeView: View { | ||
| 4 | var body: some View { | ||
| 5 | WorkView() | ||
| 6 | } | ||
| 7 | } | ||
Hutch/Views/Work/WorkView.swift added +351
| @@ -0,0 +1,351 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct WorkView: View { | ||
| 4 | private enum Scope: String, CaseIterable, Identifiable { | ||
| 5 | case all = "All" | ||
| 6 | case unread = "Unread" | ||
| 7 | case assigned = "Assigned" | ||
| 8 | |||
| 9 | var id: String { rawValue } | ||
| 10 | } | ||
| 11 | |||
| 12 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | ||
| 13 | @Environment(AppState.self) private var appState | ||
| 14 | @Environment(\.scenePhase) private var scenePhase | ||
| 15 | @State private var viewModel: HomeViewModel? | ||
| 16 | @State private var scope: Scope = .all | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | Group { | ||
| 20 | if let viewModel { | ||
| 21 | content(viewModel) | ||
| 22 | } else { | ||
| 23 | SRHTLoadingStateView(message: "Loading Work…") | ||
| 24 | } | ||
| 25 | } | ||
| 26 | .navigationTitle("Work") | ||
| 27 | .navigationBarTitleDisplayMode(.inline) | ||
| 28 | .task { | ||
| 29 | guard let currentUser = appState.currentUser else { return } | ||
| 30 | await ensureViewModel(currentUser: currentUser).loadDashboard() | ||
| 31 | } | ||
| 32 | .onChange(of: scenePhase) { _, newPhase in | ||
| 33 | guard newPhase == .active, let viewModel else { return } | ||
| 34 | Task { | ||
| 35 | await viewModel.loadDashboard() | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | @ViewBuilder | ||
| 41 | private func content(_ viewModel: HomeViewModel) -> some View { | ||
| 42 | List { | ||
| 43 | headerSection(viewModel) | ||
| 44 | scopeSection | ||
| 45 | |||
| 46 | switch scope { | ||
| 47 | case .all: | ||
| 48 | allScopeContent(viewModel) | ||
| 49 | case .unread: | ||
| 50 | unreadSection(viewModel, compactWhenEmpty: true) | ||
| 51 | case .assigned: | ||
| 52 | assignedSection(viewModel) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | .themedList() | ||
| 56 | .listStyle(.insetGrouped) | ||
| 57 | .refreshable { | ||
| 58 | await viewModel.loadDashboard() | ||
| 59 | } | ||
| 60 | .connectivityOverlay(hasContent: hasWorkContent(viewModel)) { | ||
| 61 | await viewModel.loadDashboard() | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | private func headerSection(_ viewModel: HomeViewModel) -> some View { | ||
| 66 | Section { | ||
| 67 | VStack(alignment: .leading, spacing: 6) { | ||
| 68 | Text(title(viewModel)) | ||
| 69 | .font(.headline) | ||
| 70 | if workCount(viewModel) > 0 { | ||
| 71 | Text(summary(viewModel)) | ||
| 72 | .font(.subheadline) | ||
| 73 | .foregroundStyle(.secondary) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | .padding(.vertical, 2) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | @ViewBuilder | ||
| 81 | private func allScopeContent(_ viewModel: HomeViewModel) -> some View { | ||
| 82 | if unreadCount(viewModel) > 0 { | ||
| 83 | unreadSection(viewModel, compactWhenEmpty: false) | ||
| 84 | } | ||
| 85 | |||
| 86 | assignedSection(viewModel) | ||
| 87 | |||
| 88 | if workCount(viewModel) == 0 { | ||
| 89 | Section { | ||
| 90 | WorkCompactMessageRow(text: "Nothing to do", systemImage: "checkmark.circle") | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | private var scopeSection: some View { | ||
| 96 | Section { | ||
| 97 | Picker("Scope", selection: $scope) { | ||
| 98 | ForEach(Scope.allCases) { scope in | ||
| 99 | Text(scope.rawValue).tag(scope) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | .pickerStyle(.segmented) | ||
| 103 | .listRowBackground(Color.clear) | ||
| 104 | .listRowInsets(EdgeInsets()) | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | @ViewBuilder | ||
| 109 | private func unreadSection(_ viewModel: HomeViewModel, compactWhenEmpty: Bool) -> some View { | ||
| 110 | Section { | ||
| 111 | if isLoadingUnread(viewModel) { | ||
| 112 | WorkLoadingRow(label: "Loading unread threads") | ||
| 113 | } else if viewModel.unreadInboxThreads.isEmpty { | ||
| 114 | if compactWhenEmpty { | ||
| 115 | WorkCompactMessageRow(text: "No unread threads", systemImage: "tray") | ||
| 116 | } | ||
| 117 | } else { | ||
| 118 | ForEach(viewModel.unreadInboxThreads) { thread in | ||
| 119 | NavigationLink { | ||
| 120 | ThreadDetailView( | ||
| 121 | thread: thread, | ||
| 122 | onViewed: { viewModel.markInboxThreadRead(thread) }, | ||
| 123 | onMarkRead: { viewModel.markInboxThreadRead(thread) }, | ||
| 124 | onMarkUnread: { viewModel.markInboxThreadUnread(thread) } | ||
| 125 | ) | ||
| 126 | } label: { | ||
| 127 | WorkThreadRow(thread: thread) | ||
| 128 | } | ||
| 129 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 130 | if swipeActionsEnabled { | ||
| 131 | Button { | ||
| 132 | viewModel.markInboxThreadRead(thread) | ||
| 133 | } label: { | ||
| 134 | Label("Mark Read", systemImage: "envelope.open") | ||
| 135 | } | ||
| 136 | .tint(.blue) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } header: { | ||
| 142 | Text("Unread Threads") | ||
| 143 | } footer: { | ||
| 144 | NavigationLink { | ||
| 145 | MailingListListView() | ||
| 146 | } label: { | ||
| 147 | Label("Open mailing list workspace", systemImage: "list.bullet") | ||
| 148 | .font(.subheadline.weight(.medium)) | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | @ViewBuilder | ||
| 154 | private func assignedSection(_ viewModel: HomeViewModel) -> some View { | ||
| 155 | Section { | ||
| 156 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | ||
| 157 | WorkLoadingRow(label: "Loading assigned tickets") | ||
| 158 | } else if viewModel.assignedTickets.isEmpty { | ||
| 159 | WorkCompactMessageRow(text: "No assigned tickets", systemImage: "person.crop.circle.badge.checkmark") | ||
| 160 | } else { | ||
| 161 | ForEach(viewModel.assignedTickets) { ticket in | ||
| 162 | NavigationLink { | ||
| 163 | TicketDetailView( | ||
| 164 | ownerUsername: ticket.ownerUsername, | ||
| 165 | trackerName: ticket.trackerName, | ||
| 166 | trackerId: ticket.trackerId, | ||
| 167 | trackerRid: ticket.trackerRid, | ||
| 168 | ticketId: ticket.ticket.id | ||
| 169 | ) | ||
| 170 | } label: { | ||
| 171 | WorkAssignedTicketRow(ticket: ticket) | ||
| 172 | } | ||
| 173 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 174 | if swipeActionsEnabled { | ||
| 175 | if ticket.ticket.status.isOpen { | ||
| 176 | Button { | ||
| 177 | Task { await viewModel.resolveTicket(ticket) } | ||
| 178 | } label: { | ||
| 179 | Label("Resolve", systemImage: "checkmark.circle") | ||
| 180 | } | ||
| 181 | .tint(.green) | ||
| 182 | } else { | ||
| 183 | Button { | ||
| 184 | Task { await viewModel.reopenTicket(ticket) } | ||
| 185 | } label: { | ||
| 186 | Label("Reopen", systemImage: "arrow.uturn.backward") | ||
| 187 | } | ||
| 188 | .tint(.blue) | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | ||
| 193 | if swipeActionsEnabled { | ||
| 194 | Button { | ||
| 195 | Task { await viewModel.unassignFromMe(ticket) } | ||
| 196 | } label: { | ||
| 197 | Label("Unassign", systemImage: "person.badge.minus") | ||
| 198 | } | ||
| 199 | .tint(.orange) | ||
| 200 | } | ||
| 201 | } | ||
| 202 | } | ||
| 203 | } | ||
| 204 | } header: { | ||
| 205 | Text("Assigned Tickets") | ||
| 206 | } footer: { | ||
| 207 | NavigationLink { | ||
| 208 | TrackerListView() | ||
| 209 | } label: { | ||
| 210 | Label("Open tracker workspace", systemImage: "checklist") | ||
| 211 | .font(.subheadline.weight(.medium)) | ||
| 212 | } | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | private func title(_ viewModel: HomeViewModel) -> String { | ||
| 217 | let count = workCount(viewModel) | ||
| 218 | if count == 0 { | ||
| 219 | return "Queue clear" | ||
| 220 | } | ||
| 221 | return "\(count) item\(count == 1 ? "" : "s") need attention" | ||
| 222 | } | ||
| 223 | |||
| 224 | private func summary(_ viewModel: HomeViewModel) -> String { | ||
| 225 | "\(unreadCount(viewModel)) unread • \(viewModel.assignedTickets.count) assigned" | ||
| 226 | } | ||
| 227 | |||
| 228 | private func workCount(_ viewModel: HomeViewModel) -> Int { | ||
| 229 | unreadCount(viewModel) + viewModel.assignedTickets.count | ||
| 230 | } | ||
| 231 | |||
| 232 | private func unreadCount(_ viewModel: HomeViewModel) -> Int { | ||
| 233 | viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count | ||
| 234 | } | ||
| 235 | |||
| 236 | private func isLoadingUnread(_ viewModel: HomeViewModel) -> Bool { | ||
| 237 | viewModel.unreadInboxThreadCount == nil && viewModel.unreadInboxThreads.isEmpty | ||
| 238 | } | ||
| 239 | |||
| 240 | private func hasWorkContent(_ viewModel: HomeViewModel) -> Bool { | ||
| 241 | workCount(viewModel) > 0 | ||
| 242 | } | ||
| 243 | |||
| 244 | @MainActor | ||
| 245 | private func ensureViewModel(currentUser: User) -> HomeViewModel { | ||
| 246 | if let viewModel { | ||
| 247 | return viewModel | ||
| 248 | } | ||
| 249 | |||
| 250 | let newViewModel = HomeViewModel( | ||
| 251 | currentUser: currentUser, | ||
| 252 | client: appState.client, | ||
| 253 | systemStatusRepository: appState.systemStatusRepository, | ||
| 254 | defaults: appState.accountDefaults, | ||
| 255 | accountID: appState.activeAccountID | ||
| 256 | ) | ||
| 257 | viewModel = newViewModel | ||
| 258 | return newViewModel | ||
| 259 | } | ||
| 260 | } | ||
| 261 | |||
| 262 | private struct WorkThreadRow: View { | ||
| 263 | let thread: InboxThreadSummary | ||
| 264 | |||
| 265 | var body: some View { | ||
| 266 | VStack(alignment: .leading, spacing: 6) { | ||
| 267 | HStack(alignment: .top, spacing: 8) { | ||
| 268 | Circle() | ||
| 269 | .fill(thread.isUnread ? .blue : .clear) | ||
| 270 | .frame(width: 8, height: 8) | ||
| 271 | .padding(.top, 5) | ||
| 272 | |||
| 273 | Text(thread.displaySubject) | ||
| 274 | .font(.subheadline.weight(.semibold)) | ||
| 275 | .lineLimit(2) | ||
| 276 | } | ||
| 277 | |||
| 278 | Text(thread.listDisplayName) | ||
| 279 | .font(.caption) | ||
| 280 | .foregroundStyle(.secondary) | ||
| 281 | .lineLimit(1) | ||
| 282 | |||
| 283 | Text(thread.metadataLine) | ||
| 284 | .font(.caption) | ||
| 285 | .foregroundStyle(.tertiary) | ||
| 286 | .lineLimit(1) | ||
| 287 | } | ||
| 288 | .padding(.vertical, 2) | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | private struct WorkAssignedTicketRow: View { | ||
| 293 | let ticket: HomeAssignedTicket | ||
| 294 | |||
| 295 | var body: some View { | ||
| 296 | VStack(alignment: .leading, spacing: 6) { | ||
| 297 | HStack(alignment: .firstTextBaseline, spacing: 8) { | ||
| 298 | Text("#\(ticket.ticket.id)") | ||
| 299 | .font(.caption.monospacedDigit()) | ||
| 300 | .foregroundStyle(.secondary) | ||
| 301 | |||
| 302 | Text(ticket.ticket.title) | ||
| 303 | .font(.subheadline.weight(.semibold)) | ||
| 304 | .lineLimit(2) | ||
| 305 | |||
| 306 | Spacer(minLength: 8) | ||
| 307 | |||
| 308 | Text(ticket.ticket.status.displayName) | ||
| 309 | .font(.caption2.weight(.semibold)) | ||
| 310 | .foregroundStyle(ticket.ticket.status.isOpen ? .orange : .secondary) | ||
| 311 | } | ||
| 312 | |||
| 313 | Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName)") | ||
| 314 | .font(.caption) | ||
| 315 | .foregroundStyle(.secondary) | ||
| 316 | .lineLimit(1) | ||
| 317 | |||
| 318 | Text(ticket.ticket.created.relativeDescription) | ||
| 319 | .font(.caption) | ||
| 320 | .foregroundStyle(.tertiary) | ||
| 321 | } | ||
| 322 | .padding(.vertical, 2) | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | private struct WorkCompactMessageRow: View { | ||
| 327 | let text: String | ||
| 328 | let systemImage: String | ||
| 329 | |||
| 330 | var body: some View { | ||
| 331 | Label(text, systemImage: systemImage) | ||
| 332 | .font(.caption) | ||
| 333 | .foregroundStyle(.secondary) | ||
| 334 | .padding(.vertical, 2) | ||
| 335 | } | ||
| 336 | } | ||
| 337 | |||
| 338 | private struct WorkLoadingRow: View { | ||
| 339 | let label: String | ||
| 340 | |||
| 341 | var body: some View { | ||
| 342 | HStack(spacing: 10) { | ||
| 343 | ProgressView() | ||
| 344 | .controlSize(.small) | ||
| 345 | Text(label) | ||
| 346 | .font(.subheadline) | ||
| 347 | .foregroundStyle(.secondary) | ||
| 348 | } | ||
| 349 | .padding(.vertical, 4) | ||
| 350 | } | ||
| 351 | } | ||
labels.sh deleted −96
| @@ -1,96 +0,0 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | TRACKER="hutch" | ||
| 5 | ERR_FILE="$(mktemp)" | ||
| 6 | trap 'rm -f "$ERR_FILE"' EXIT | ||
| 7 | |||
| 8 | label_ticket() { | ||
| 9 | local ticket_id="$1" | ||
| 10 | shift | ||
| 11 | |||
| 12 | for label in "$@"; do | ||
| 13 | echo "Applying label '$label' to ticket #$ticket_id" | ||
| 14 | if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then | ||
| 15 | if grep -q "already assigned to this ticket" "$ERR_FILE"; then | ||
| 16 | echo " already present, skipping" | ||
| 17 | else | ||
| 18 | cat "$ERR_FILE" >&2 | ||
| 19 | exit 1 | ||
| 20 | fi | ||
| 21 | fi | ||
| 22 | done | ||
| 23 | } | ||
| 24 | |||
| 25 | # v2.16.x | ||
| 26 | label_ticket 14 enhancement ui ux # Home: Collapsible Sections | ||
| 27 | label_ticket 15 enhancement ui # Home: See All Navigation | ||
| 28 | label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping | ||
| 29 | |||
| 30 | label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle | ||
| 31 | label_ticket 18 enhancement builds # Builds: Per-Repo Filter | ||
| 32 | label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish | ||
| 33 | |||
| 34 | label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions | ||
| 35 | label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility | ||
| 36 | |||
| 37 | label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes | ||
| 38 | label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish | ||
| 39 | |||
| 40 | label_ticket 24 new-feature theming ui # Theming: AMOLED Mode | ||
| 41 | label_ticket 25 new-feature theming ui # Theming: High-Density Mode | ||
| 42 | |||
| 43 | # v2.17.x | ||
| 44 | label_ticket 26 new-feature projects # Projects: Project List View | ||
| 45 | label_ticket 27 new-feature projects # Projects: Project Detail View | ||
| 46 | label_ticket 28 enhancement projects ui # Projects: Pin to Home | ||
| 47 | |||
| 48 | label_ticket 29 enhancement builds # Builds: Log Search | ||
| 49 | label_ticket 30 enhancement builds # Builds: Jump to Error | ||
| 50 | label_ticket 31 enhancement builds # Builds: Artifact List | ||
| 51 | |||
| 52 | label_ticket 32 enhancement tickets # Tickets: Saved Filters | ||
| 53 | label_ticket 33 enhancement tickets # Tickets: Label Filtering | ||
| 54 | |||
| 55 | label_ticket 34 enhancement inbox # Inbox: Basic Threading | ||
| 56 | label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs | ||
| 57 | |||
| 58 | # v2.18.x | ||
| 59 | label_ticket 36 new-feature projects # Projects: Create Project | ||
| 60 | label_ticket 37 enhancement projects # Projects: Edit Project | ||
| 61 | label_ticket 38 enhancement projects # Projects: Manage Linked Resources | ||
| 62 | |||
| 63 | label_ticket 39 new-feature acl repo # ACL: View Permissions | ||
| 64 | label_ticket 40 enhancement acl repo # ACL: Add and Remove Users | ||
| 65 | label_ticket 41 enhancement acl repo # ACL: Edit Permissions | ||
| 66 | |||
| 67 | label_ticket 42 enhancement repo # Repo Settings: Edit Metadata | ||
| 68 | label_ticket 43 enhancement repo # Repo Settings: Set Default Branch | ||
| 69 | label_ticket 44 enhancement repo # Repo Settings: Visibility Changes | ||
| 70 | |||
| 71 | label_ticket 45 new-feature tickets # Tickets: Label CRUD | ||
| 72 | label_ticket 46 enhancement tickets # Tickets: Bulk Actions | ||
| 73 | |||
| 74 | # v2.19.x | ||
| 75 | label_ticket 47 new-feature search # Search: Per-Type Search | ||
| 76 | label_ticket 48 enhancement search # Search: Recent Searches | ||
| 77 | |||
| 78 | label_ticket 49 new-feature accounts # Accounts: Multi-Account Support | ||
| 79 | label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI | ||
| 80 | label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching | ||
| 81 | |||
| 82 | label_ticket 52 enhancement power # Power: Open in Browser | ||
| 83 | label_ticket 53 enhancement power # Power: Copy Actions | ||
| 84 | label_ticket 54 enhancement power # Power: Debug View Toggle | ||
| 85 | |||
| 86 | # v2.20.x | ||
| 87 | label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation | ||
| 88 | |||
| 89 | label_ticket 56 enhancement performance # Performance: Caching Improvements | ||
| 90 | label_ticket 57 enhancement performance # Performance: List Virtualization | ||
| 91 | |||
| 92 | label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity | ||
| 93 | |||
| 94 | label_ticket 59 bug api # Errors: Normalize GraphQL Handling | ||
| 95 | |||
| 96 | echo "Done." | ||