Commit 8fc97b98ae

8fc97b98aeac7ae0c8922dbdb0f4208fd66ef3cd

parent: 707a3efe1e

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 18:09 UTC

release: v3.0.0 — navigation overhaul, Work view, and multi-pin support

Reworks the app's core navigation and home screen for v3.0.0:

- Replace Inbox with Work view: unified dashboard showing unread threads
  and assigned tickets, with All/Unread/Assigned scope picker
- Overhaul Home screen: redesigned with pinned items grid (trackers,
  repos, mailing lists, users), recent activity section, and system
  status banner; backed by HomePinStore supporting all resource types
- Simplify navigation bars across list screens: default toolbar shows
  only the primary action (+) and an overflow menu (…); pin, share, and
  select moved into the overflow menu; selection mode gets its own nav
  bar with Cancel / "N Selected" / All
- Consolidate repo detail toolbars: pin and share moved into the
  existing actions menu for both Git and Mercurial detail views
- Add recent activity tracking via RecentActivityStore
- Add work and inbox deep link aliases (hutch://work, hutch://inbox)

Implements: https://todo.sr.ht/~ccleberg/hutch/55

Layout: unified · split

Hutch/App/AppStorageKeys.swift +2
@@ -5,12 +5,14 @@ enum AppStorageKeys {
55 static let activeAccountID = "activeAccountID"
66 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
77 static let lookupHistory = "lookupHistory"
8 static let recentActivity = "recentActivity"
89 static let scopedSearchHistory = "scopedSearchHistory"
910 static let hutchStatsBaseURL = "hutchStatsBaseURL"
1011 static let systemStatusSnapshotCache = "systemStatusSnapshotCache"
1112 static let systemStatusIncidentCache = "systemStatusIncidentCache"
1213 static let homeProjectsExpanded = "homeProjectsExpanded"
1314 static let pinnedHomeProjects = "pinnedHomeProjects"
15 static let pinnedHomeItems = "pinnedHomeItems"
1416 static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded"
1517 static let homeBuildsExpanded = "homeBuildsExpanded"
1618 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
Hutch/App/DeepLink.swift +4
@@ -3,6 +3,7 @@ import Foundation
33/// Represents a parsed `hutch://` deep link.
44enum DeepLink: Equatable {
55 case home
6 case work
67 /// hutch://git/<owner>/<repo>
78 case repository(owner: String, repo: String)
89 /// hutch://todo/<owner>/<tracker>/<ticketId>
@@ -33,6 +34,9 @@ enum DeepLink: Equatable {
3334 case "home", nil:
3435 self = .home
3536
37 case "work", "inbox":
38 self = .work
39
3640 case "git" where components.count >= 3:
3741 let owner = components[1]
3842 let repo = components[2]
Hutch/App/HutchApp.swift +1 −1
@@ -26,7 +26,7 @@ struct HutchApp: App {
2626 let link: DeepLink
2727 switch destination {
2828 case .home: link = .home
29 case .inbox: link = .home
29 case .work: link = .work
3030 case .builds: link = .buildsTab
3131 case .repositories: link = .repositoriesTab
3232 case .trackers: link = .trackersTab
Hutch/App/HutchIntents.swift +2 −2
@@ -5,7 +5,7 @@ import Foundation
55
66enum HutchDestination: String, AppEnum {
77 case home
8 case inbox
8 case work
99 case builds
1010 case repositories
1111 case trackers
@@ -16,7 +16,7 @@ enum HutchDestination: String, AppEnum {
1616
1717 static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [
1818 .home: "Home",
19 .inbox: "Inbox",
19 .work: "Work",
2020 .builds: "Builds",
2121 .repositories: "Repositories",
2222 .trackers: "Trackers",
Hutch/App/RootView.swift +14
@@ -69,6 +69,12 @@ struct RootView: View {
6969 return TabView(selection: $appState.selectedTab) {
7070 NavigationStack(path: $homePath) {
7171 HomeView()
72 .navigationDestination(for: HomeRoute.self) { route in
73 switch route {
74 case .work:
75 WorkView()
76 }
77 }
7278 }
7379 .tag(AppState.Tab.home)
7480 .tabItem {
@@ -196,6 +202,14 @@ struct RootView: View {
196202 case .ticket(let owner, let tracker, let ticketId):
197203 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
198204
205 case .work:
206 homePath = NavigationPath()
207 appState.selectedTab = .home
208 Task {
209 await settleNavigationTransition()
210 homePath.append(HomeRoute.work)
211 }
212
199213 case .buildsTab:
200214 buildsPath = NavigationPath()
201215 appState.selectedTab = .builds
Hutch/Views/Builds/BuildDetailView.swift +17
@@ -331,6 +331,13 @@ struct BuildDetailView: View {
331331 }
332332 }
333333 }
334 .task(id: job.id) {
335 RecentActivityStore.recordBuild(
336 jobId: job.id,
337 title: recentActivityTitle(for: job),
338 defaults: appState.accountDefaults
339 )
340 }
334341 .refreshable {
335342 await reloadDetail(viewModel)
336343 }
@@ -367,6 +374,16 @@ struct BuildDetailView: View {
367374 await viewModel.loadJob()
368375 }
369376 }
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 }
370387}
371388
372389private struct BuildArtifactRow: View {
Hutch/Views/Home/HomePinStore.swift added +207
@@ -0,0 +1,207 @@
1import Foundation
2
3enum HomePinKind: String, Codable, Sendable {
4 case project
5 case repository
6 case tracker
7 case mailingList
8 case user
9}
10
11struct 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
88enum 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 @@
1import SwiftUI
2
3struct HomePrototypeView: View {
4 var body: some View {
5 HomeView()
6 }
7}
Hutch/Views/Home/HomeView.swift +381 −735
@@ -1,15 +1,13 @@
11import SwiftUI
22
33struct 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
84 @Environment(AppState.self) private var appState
95 @Environment(\.scenePhase) private var scenePhase
106 @State private var viewModel: HomeViewModel?
11 private let previewLimit = 4
12 private let projectPreviewLimit = 3
7 @State private var recentItems: [RecentActivityEntry] = []
8 @State private var isOpeningRecentItem = false
9 @State private var selectedPinnedProject: Project?
10 @State private var selectedPinnedUser: User?
1311
1412 var body: some View {
1513 Group {
@@ -20,39 +18,40 @@ struct HomeView: View {
2018 }
2119 }
2220 .navigationTitle("Home")
23 .toolbar {
24 ToolbarItem(placement: .topBarTrailing) {
25 NavigationLink {
26 InboxView()
27 } label: {
28 HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
21 .navigationDestination(isPresented: Binding(
22 get: { selectedPinnedProject != nil },
23 set: { isPresented in
24 if !isPresented {
25 selectedPinnedProject = nil
2926 }
3027 }
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 }
3144 }
3245 .task {
3346 guard let currentUser = appState.currentUser else { return }
34
35 let vm: HomeViewModel
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()
47 await ensureViewModel(currentUser: currentUser).loadDashboard()
48 loadRecentActivity()
5149 }
5250 .onChange(of: scenePhase) { _, newPhase in
5351 guard newPhase == .active, let viewModel else { return }
5452 Task {
5553 await viewModel.loadDashboard()
54 loadRecentActivity()
5655 }
5756 }
5857 }
@@ -60,850 +59,497 @@ struct HomeView: View {
6059 @ViewBuilder
6160 private func content(_ viewModel: HomeViewModel) -> some View {
6261 List {
63 attentionSection(viewModel)
64 systemStatusBannerSection(viewModel)
65 inboxSection(viewModel)
66 projectsSection(viewModel)
67 assignedTicketsSection(viewModel)
68 recentBuildsSection(viewModel)
62 systemStatusSection(viewModel)
63 workSection(viewModel)
64 recentSection
65 buildsSection(viewModel)
66 pinnedSection(viewModel)
6967 }
7068 .themedList()
7169 .listStyle(.insetGrouped)
72 .overlay {
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 }
70 .listSectionSpacing(.compact)
7971 .refreshable {
8072 await viewModel.loadDashboard()
8173 }
82 .connectivityOverlay(hasContent: viewModel.hasDashboardContent) {
74 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
8375 await viewModel.loadDashboard()
8476 }
85 }
86
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 )
77 .onAppear {
78 loadRecentActivity()
11979 }
12080 }
12181
12282 @ViewBuilder
123 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
124 Section {
125 NavigationLink {
126 SystemStatusView()
127 } label: {
128 SystemStatusSummaryRow(
129 snapshot: viewModel.systemStatusSnapshot,
130 isLoading: viewModel.isLoadingSystemStatus,
131 errorMessage: viewModel.systemStatusErrorMessage,
132 isShowingStaleData: viewModel.isShowingStaleSystemStatus
133 )
83 private func systemStatusSection(_ viewModel: HomeViewModel) -> some View {
84 if viewModel.systemStatusSnapshot?.hasDisruption == true {
85 Section {
86 NavigationLink {
87 SystemStatusView()
88 } label: {
89 SystemStatusSummaryRow(
90 snapshot: viewModel.systemStatusSnapshot,
91 isLoading: viewModel.isLoadingSystemStatus,
92 errorMessage: viewModel.systemStatusErrorMessage,
93 isShowingStaleData: viewModel.isShowingStaleSystemStatus
94 )
95 }
96 .buttonStyle(.plain)
13497 }
135 .buttonStyle(.plain)
13698 }
13799 }
138100
139 @ViewBuilder
140 private func inboxSection(_ viewModel: HomeViewModel) -> some View {
141 Section {
142 if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 {
143 HomeSectionMessageRow(
144 text: "No unread inbox threads.",
145 systemImage: "tray"
146 )
147 } else if viewModel.unreadInboxThreads.isEmpty {
148 HomeSectionMessageRow(
149 text: viewModel.inboxSummaryText,
150 systemImage: "tray"
101 private func workSection(_ viewModel: HomeViewModel) -> some View {
102 Section("Work") {
103 NavigationLink(value: HomeRoute.work) {
104 HomeSummaryRow(
105 title: workTitle(viewModel),
106 summary: workSummary(viewModel),
107 systemImage: "tray.full",
108 tint: workCount(viewModel) > 0 ? .blue : .secondary,
109 emphasis: .action
151110 )
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()
169111 }
170112 }
171113 }
172114
173115 @ViewBuilder
174 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
175 if viewModel.hasPinnedProjects {
176 HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
177 NavigationLink {
178 ProjectsListView()
179 } label: {
180 Text("See All")
181 .font(.caption.weight(.medium))
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 }
116 private var recentSection: some View {
117 if !recentItems.isEmpty {
118 Section("Recent") {
119 ForEach(recentItems) { item in
120 Button {
121 openRecentItem(item)
122 } label: {
123 HomeRecentRow(item: item)
206124 }
125 .buttonStyle(.plain)
126 .disabled(isOpeningRecentItem)
127 .listRowSeparator(.hidden)
207128 }
208129 }
209130 }
210131 }
211132
212 @ViewBuilder
213 private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
214 HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
133 private func buildsSection(_ viewModel: HomeViewModel) -> some View {
134 Section("Builds") {
215135 NavigationLink {
216 HomeAssignedTicketsListView(viewModel: viewModel)
136 BuildListView()
217137 } label: {
218 Text("See All")
219 .font(.caption.weight(.medium))
220 }
221 .buttonStyle(.plain)
222 } content: {
223 if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
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"
138 HomeSummaryRow(
139 title: buildsTitle(viewModel),
140 summary: buildsSummary(viewModel),
141 systemImage: "hammer",
142 tint: viewModel.failedBuildCount > 0 ? .orange : .secondary,
143 emphasis: .monitoring
236144 )
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 }
268145 }
269146 }
270147 }
271148
272 @ViewBuilder
273 private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
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 }
149 private func pinnedSection(_ viewModel: HomeViewModel) -> some View {
150 let items = pinnedItems(viewModel)
304151
305 ForEach(group.builds) { build in
306 NavigationLink {
307 BuildDetailView(jobId: build.job.id)
152 return Section("Pinned") {
153 if items.isEmpty {
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)
308170 } label: {
309 HomeBuildRow(
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 }
171 HomePinnedCard(item: item)
326172 }
173 .buttonStyle(.plain)
327174 }
328175 }
176 .padding(.vertical, 2)
329177 }
330178 }
331179 }
332180
333 private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
334 let previewBuilds = Array(builds.prefix(previewLimit))
335 guard let firstBuild = previewBuilds.first else { return [] }
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 }
181 private func workCount(_ viewModel: HomeViewModel) -> Int {
182 unreadCount(viewModel) + viewModel.assignedTickets.count
183 }
355184
356 return groups
185 private func unreadCount(_ viewModel: HomeViewModel) -> Int {
186 viewModel.unreadInboxThreadCount ?? viewModel.unreadInboxThreads.count
357187 }
358188
359 @ViewBuilder
360 private func ticketLeadingSwipeAction(
361 _ ticket: HomeAssignedTicket,
362 viewModel: HomeViewModel
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)
189 private func workTitle(_ viewModel: HomeViewModel) -> String {
190 let count = workCount(viewModel)
191 if count == 0 {
192 return "Queue clear"
382193 }
194 return "\(count) item\(count == 1 ? "" : "s") need attention"
383195 }
384196
385}
386
387private struct HomeInboxToolbarIcon: View {
388 let hasUnreadThreads: Bool
389
390 var body: some View {
391 Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
392 .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
197 private func workSummary(_ viewModel: HomeViewModel) -> String {
198 let unread = unreadCount(viewModel)
199 let assigned = viewModel.assignedTickets.count
200 return "\(unread) unread • \(assigned) assigned"
393201 }
394}
395
396private 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 }
415202
416 Spacer(minLength: 8)
203 private func buildsTitle(_ viewModel: HomeViewModel) -> String {
204 let failed = viewModel.failedBuildCount
205 let running = viewModel.activeBuildCount
417206
418 VisibilityBadge(visibility: project.visibility)
419 }
420
421 Text(project.metadataLine)
422 .font(.caption)
423 .foregroundStyle(.secondary)
424 .lineLimit(1)
207 if failed == 0 && running == 0 {
208 return "Build monitoring clear"
425209 }
426 .contentShape(Rectangle())
427 .padding(.vertical, 4)
428 }
429}
430
431private 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 }
210 if failed > 0 {
211 return "\(failed) failed build\(failed == 1 ? "" : "s")"
483212 }
484 .padding(.vertical, 2)
213 return "\(running) running build\(running == 1 ? "" : "s")"
485214 }
486215
487 private func openRepository() {
488 guard let repositoryName = build.repositoryName,
489 let repositoryOwner = build.repositoryOwner else { return }
490 Task {
491 do {
492 let repository = try await appState.resolveRepository(
493 owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner,
494 name: repositoryName
495 )
496 appState.navigateToRepository(repository)
497 } catch {
498 appState.presentRepositoryDeepLinkError()
499 }
216 private func buildsSummary(_ viewModel: HomeViewModel) -> String {
217 let failed = viewModel.failedBuildCount
218 let running = viewModel.activeBuildCount
219 if failed == 0 && running == 0 {
220 return "No failures • \(buildTimeframeLabel(viewModel))"
221 }
222 if failed > 0 && running > 0 {
223 return "\(failed) failed • \(running) running • \(buildTimeframeLabel(viewModel))"
500224 }
225 if failed > 0 {
226 return "\(failed) failed • \(buildTimeframeLabel(viewModel))"
227 }
228 return "\(running) running • \(buildTimeframeLabel(viewModel))"
501229 }
502}
503230
504private struct HomeBuildGroup: Identifiable {
505 enum Identity: Hashable {
506 case repository(owner: String?, name: String)
507 case standalone(Int)
231 private func pinnedItems(_ viewModel: HomeViewModel) -> [HomePinnedItem] {
232 let currentUserKey = appState.currentUser?.canonicalName ?? ""
233 let pins = HomePinStore.loadPins(for: currentUserKey, defaults: appState.accountDefaults)
234 let projectsByID = Dictionary(uniqueKeysWithValues: viewModel.projects.map { ($0.id, $0) })
508235
509 init(build: HomeBuildItem) {
510 if let repositoryName = build.repositoryName {
511 self = .repository(owner: build.repositoryOwner, name: repositoryName)
512 } else {
513 self = .standalone(build.id)
236 return pins.compactMap { pin in
237 switch pin.kind {
238 case .project:
239 guard let project = projectsByID[pin.value] else { return nil }
240 return HomePinnedItem(pin: pin, project: project)
241 case .repository, .tracker, .mailingList, .user:
242 return HomePinnedItem(pin: pin, project: nil)
514243 }
515244 }
516245 }
517246
518 let identity: Identity
519 let builds: [HomeBuildItem]
247 private func buildTimeframeLabel(_ viewModel: HomeViewModel) -> String {
248 let calendar = Calendar.current
249 let buildDates = viewModel.recentBuilds.map(\.job.updated)
520250
521 var id: String {
522 switch identity {
523 case .repository(let owner, let name):
524 return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
525 case .standalone(let jobId):
526 return "job-\(jobId)"
251 guard !buildDates.isEmpty else {
252 return "today"
527253 }
528 }
529254
530 var repositoryDisplayName: String? {
531 builds.first?.repositoryDisplayName
255 return buildDates.allSatisfy(calendar.isDateInToday) ? "today" : "this week"
532256 }
533257
534 var latestStatus: JobStatus {
535 builds.first?.job.status ?? .pending
258 private func hasHomeContent(_ viewModel: HomeViewModel) -> Bool {
259 viewModel.systemStatusSnapshot?.hasDisruption == true ||
260 workCount(viewModel) > 0 ||
261 !recentItems.isEmpty ||
262 !pinnedItems(viewModel).isEmpty
536263 }
537}
538
539private struct HomeBuildGroupHeader: View {
540 let repositoryDisplayName: String
541 let buildCount: Int
542 let latestStatus: JobStatus
543264
544 var body: some View {
545 HStack(spacing: 12) {
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)
265 private func loadRecentActivity() {
266 recentItems = RecentActivityStore.load(defaults: appState.accountDefaults)
563267 }
564}
565268
566private struct HomeAssignedTicketRow: View {
567 @Environment(AppState.self) private var appState
568 let ticket: HomeAssignedTicket
269 private func openRecentItem(_ item: RecentActivityEntry) {
270 guard !isOpeningRecentItem else { return }
569271
570 var body: some View {
571 VStack(alignment: .leading, spacing: 6) {
572 HStack(alignment: .top, spacing: 12) {
573 TicketStatusIcon(status: ticket.ticket.status)
574 .frame(width: 20)
575
576 VStack(alignment: .leading, spacing: 4) {
577 Text(ticket.ticket.title)
578 .font(.subheadline.weight(.medium))
579 .lineLimit(2)
580
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()
272 switch item.kind {
273 case .build:
274 guard let jobId = item.buildJobId else { return }
275 appState.navigateToBuild(jobId: jobId)
276 case .ticket:
277 guard
278 let ownerUsername = item.ticketOwnerUsername,
279 let trackerName = item.ticketTrackerName,
280 let ticketId = item.ticketId
281 else {
282 return
595283 }
596
597 Button {
598 openTracker()
599 } label: {
600 Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
601 .font(.caption)
602 .foregroundStyle(.secondary)
284 appState.navigateToTicket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId)
285 case .repository:
286 guard
287 let owner = item.repositoryOwner,
288 let name = item.repositoryName
289 else {
290 return
603291 }
604 .buttonStyle(.plain)
605 }
606 .padding(.vertical, 2)
607 }
608292
609 private func openTracker() {
610 Task {
611 do {
612 let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
613 appState.navigateToTracker(tracker)
614 } catch {
615 appState.presentTicketDeepLinkError()
293 isOpeningRecentItem = true
294 Task {
295 defer { isOpeningRecentItem = false }
296 do {
297 let repository = try await appState.resolveRepository(
298 owner: owner,
299 name: name,
300 service: item.repositoryService ?? .git
301 )
302 appState.navigateToRepository(repository)
303 } catch {
304 appState.presentRepositoryDeepLinkError()
305 }
616306 }
617307 }
618308 }
619}
620309
621private struct HomeInboxThreadRow: View {
622 @Environment(AppState.self) private var appState
623 let thread: InboxThreadSummary
624
625 var body: some View {
626 VStack(alignment: .leading, spacing: 6) {
627 HStack(alignment: .top, spacing: 10) {
628 Circle()
629 .fill(.blue)
630 .frame(width: 8, height: 8)
631 .padding(.top, 6)
632
633 VStack(alignment: .leading, spacing: 4) {
634 Text(thread.displaySubject)
635 .font(.subheadline.weight(.medium))
636 .lineLimit(2)
637
638 Text(thread.metadataLine)
639 .font(.caption)
640 .foregroundStyle(.secondary)
641 .lineLimit(1)
310 private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
311 switch item.pin.kind {
312 case .project:
313 guard let project = item.project else { return }
314 selectedPinnedProject = project
315 case .repository:
316 guard
317 let owner = item.pin.ownerUsername,
318 let service = item.pin.service
319 else {
320 return
321 }
322 isOpeningRecentItem = true
323 Task {
324 defer { isOpeningRecentItem = false }
325 do {
326 let repository = try await appState.resolveRepository(owner: owner, name: item.pin.value, service: service)
327 appState.navigateToRepository(repository)
328 } catch {
329 appState.presentRepositoryDeepLinkError()
642330 }
643331 }
644
645 HStack(spacing: 10) {
646 Button {
647 appState.navigateToMailingList(
648 InboxMailingListReference(
649 id: thread.listID,
650 rid: thread.listRID,
651 name: thread.listName,
652 owner: thread.listOwner
653 )
654 )
655 } label: {
656 Label(thread.listName, systemImage: "list.bullet")
657 .font(.caption)
658 .foregroundStyle(.secondary)
332 case .tracker:
333 guard let owner = item.pin.ownerUsername else { return }
334 isOpeningRecentItem = true
335 Task {
336 defer { isOpeningRecentItem = false }
337 do {
338 let tracker = try await appState.resolveTracker(owner: owner, name: item.pin.value)
339 appState.navigateToTracker(tracker)
340 } catch {
341 appState.presentTicketDeepLinkError()
659342 }
660 .buttonStyle(.plain)
661
662 if let repo = thread.repo {
663 Button {
664 openRepository(named: repo)
665 } label: {
666 Label(repo, systemImage: "book.closed")
667 .font(.caption)
668 .foregroundStyle(.secondary)
669 }
670 .buttonStyle(.plain)
343 }
344 case .mailingList:
345 guard let ownerUsername = item.pin.ownerUsername else { return }
346 appState.openMailingList(
347 InboxMailingListReference(
348 id: 0,
349 rid: item.pin.value,
350 name: item.pin.title,
351 owner: Entity(canonicalName: "~\(ownerUsername)")
352 )
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
671361 }
672362 }
673363 }
674 .padding(.vertical, 2)
675364 }
676365
677 private func openRepository(named repositoryName: String) {
678 Task {
679 do {
680 let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~")
681 ? String(thread.listOwner.canonicalName.dropFirst())
682 : thread.listOwner.canonicalName
683 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
684 appState.navigateToRepository(repository)
685 } catch {
686 appState.presentRepositoryDeepLinkError()
366 private func resolvePinnedUser(username: String) async throws -> User {
367 struct Response: Decodable, Sendable {
368 let user: User
369 }
370
371 let query = """
372 query userLookup($username: String!) {
373 user: userByName(username: $username) {
374 id
375 created
376 updated
377 canonicalName
378 username
379 email
380 url
381 location
382 bio
383 avatar
384 pronouns
385 userType
687386 }
688387 }
689 }
690}
388 """
691389
692private struct HomeSectionLoadingRow: View {
693 let label: String
390 let result = try await appState.client.execute(
391 service: .meta,
392 query: query,
393 variables: ["username": username],
394 responseType: Response.self
395 )
396 return result.user
397 }
694398
695 var body: some View {
696 HStack(spacing: 10) {
697 ProgressView()
698 .controlSize(.small)
699 Text(label)
700 .foregroundStyle(.secondary)
399 @MainActor
400 private func ensureViewModel(currentUser: User) -> HomeViewModel {
401 if let viewModel {
402 return viewModel
701403 }
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
703414 }
704415}
705416
706private struct HomeSectionHeader<Destination: View>: View {
707 let title: String
708 let destination: Destination
417enum HomeRoute: Hashable {
418 case work
419}
709420
710 init(_ title: String, @ViewBuilder destination: () -> Destination) {
711 self.title = title
712 self.destination = destination()
713 }
421private enum HomeSummaryEmphasis {
422 case action
423 case monitoring
424}
714425
715 var body: some View {
716 HStack {
717 Text(title)
718 Spacer()
719 NavigationLink {
720 destination
721 } label: {
722 Text("See All")
723 .font(.caption.weight(.medium))
724 }
725 .buttonStyle(.plain)
726 }
727 .textCase(nil)
728 }
426private struct HomePinnedItem: Identifiable {
427 let pin: HomePinRecord
428 let project: Project?
429
430 var id: String { pin.id }
431 var title: String { project?.displayName ?? pin.title }
432 var detail: String { pin.subtitle }
729433}
730434
731private struct HomeAttentionSummaryRow: View {
435private struct HomeSummaryRow: View {
732436 let title: String
733437 let summary: String
438 let systemImage: String
439 let tint: Color
440 let emphasis: HomeSummaryEmphasis
734441
735442 var body: some View {
736 VStack(alignment: .leading, spacing: 4) {
737 Text(title)
443 HStack(spacing: 10) {
444 Image(systemName: systemImage)
738445 .font(.subheadline.weight(.semibold))
739 Text(summary)
740 .font(.caption)
741 .foregroundStyle(.secondary)
742 .lineLimit(2)
446 .foregroundStyle(iconColor)
447 .frame(width: 18)
448
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)
743459 }
744 .padding(.vertical, 2)
460 .padding(.vertical, verticalPadding)
745461 }
746}
747462
748private struct HomeAttentionLinkRow<Destination: View>: View {
749 let title: String
750 let summary: String
751 let countText: String
752 let destination: Destination?
753 let action: (() -> Void)?
754
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
463 private var iconColor: Color {
464 switch emphasis {
465 case .action:
466 return tint
467 case .monitoring:
468 return tint.opacity(0.9)
469 }
766470 }
767471
768 init(
769 title: String,
770 summary: String,
771 countText: String,
772 action: @escaping () -> Void
773 ) where Destination == EmptyView {
774 self.title = title
775 self.summary = summary
776 self.countText = countText
777 self.destination = nil
778 self.action = action
472 private var verticalPadding: CGFloat {
473 switch emphasis {
474 case .action:
475 return 3
476 case .monitoring:
477 return 2
478 }
779479 }
480}
481
482private struct HomeRecentRow: View {
483 let item: RecentActivityEntry
780484
781485 var body: some View {
782 Group {
783 if let destination {
784 NavigationLink {
785 destination
786 } label: {
787 content
788 }
789 } else if let action {
790 Button(action: action) {
791 content
792 }
793 .buttonStyle(.plain)
794 }
795 }
796 }
486 HStack(spacing: 10) {
487 Image(systemName: iconName)
488 .font(.caption.weight(.semibold))
489 .foregroundStyle(.secondary)
490 .frame(width: 16)
797491
798 private var content: some View {
799 HStack(spacing: 12) {
800 VStack(alignment: .leading, spacing: 4) {
801 Text(title)
492 VStack(alignment: .leading, spacing: 1) {
493 Text(item.title)
802494 .font(.subheadline.weight(.medium))
803 Text(summary)
495 .lineLimit(1)
496 Text(item.detailText)
804497 .font(.caption)
805498 .foregroundStyle(.secondary)
806499 .lineLimit(1)
807500 }
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())
815501
816 if action != nil {
817 Image(systemName: "chevron.right")
818 .font(.caption.weight(.semibold))
819 .foregroundStyle(.tertiary)
820 }
502 Spacer(minLength: 8)
503 }
504 .padding(.vertical, 1)
505 }
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"
821515 }
822 .frame(maxWidth: .infinity, alignment: .leading)
823 .contentShape(Rectangle())
824 .padding(.vertical, 2)
825516 }
826517}
827518
828private struct HomeAssignedTicketsListView: View {
829 let viewModel: HomeViewModel
830 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
519private struct HomePinnedCard: View {
520 let item: HomePinnedItem
831521
832522 var body: some View {
833 List {
834 ForEach(viewModel.assignedTickets) { ticket in
835 NavigationLink {
836 TicketDetailView(
837 ownerUsername: ticket.ownerUsername,
838 trackerName: ticket.trackerName,
839 trackerId: ticket.trackerId,
840 trackerRid: ticket.trackerRid,
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 }
523 VStack(alignment: .leading, spacing: 6) {
524 HStack(spacing: 6) {
525 Image(systemName: "square.stack.3d.up")
526 .font(.caption.weight(.semibold))
527 .foregroundStyle(.secondary)
528 Text(item.detail)
529 .font(.caption2.weight(.semibold))
530 .foregroundStyle(.secondary)
875531 }
876532
877 if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
878 HomeSectionMessageRow(
879 text: "No open tickets assigned to you.",
880 systemImage: "person.crop.circle.badge.checkmark"
881 )
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 }
533 Text(item.title)
534 .font(.subheadline.weight(.semibold))
535 .lineLimit(2)
536
537 Spacer(minLength: 0)
893538 }
539 .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
540 .padding(10)
541 .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
894542 }
895543}
896544
897private struct HomeSectionMessageRow: View {
545private struct HomeCompactMessageRow: View {
898546 let text: String
899547 let systemImage: String
900 var emphasized = false
901 var accessibilityHint: String? = nil
902548
903549 var body: some View {
904550 Label(text, systemImage: systemImage)
905 .font(.subheadline)
906 .foregroundStyle(emphasized ? .secondary : .tertiary)
907 .accessibilityHint(accessibilityHint ?? "")
551 .font(.caption)
552 .foregroundStyle(.secondary)
553 .padding(.vertical, 2)
908554 }
909555}
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -393,7 +393,7 @@ final class HomeViewModel {
393393
394394 let inboxUnreadSnapshot = await inboxUnreadTask
395395 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
396 unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
396 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
397397 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
398398 let systemStatusResult = await systemStatusTask
399399 switch systemStatusResult {
@@ -414,7 +414,7 @@ final class HomeViewModel {
414414 }
415415
416416 var pinnedProjects: [Project] {
417 let pinnedIDs = ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults)
417 let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults)
418418 guard !pinnedIDs.isEmpty else { return [] }
419419
420420 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
@@ -422,7 +422,7 @@ final class HomeViewModel {
422422 }
423423
424424 var hasPinnedProjects: Bool {
425 !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey, defaults: defaults).isEmpty
425 !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty
426426 }
427427
428428 var failedBuildCount: Int {
Hutch/Views/Home/RecentActivityStore.swift added +148
@@ -0,0 +1,148 @@
1import Foundation
2
3enum RecentActivityKind: String, Codable, Sendable {
4 case repository
5 case ticket
6 case build
7}
8
9struct 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
46enum 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 @@
11import SwiftUI
22
3// Legacy wrapper kept temporarily so stale references continue to compile while
4// the app transitions from Inbox to Work.
35struct 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
116 var body: some View {
12 Group {
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
7 WorkView()
1948 }
1959}
19610
Hutch/Views/Lookup/UserProfileView.swift +29
@@ -6,6 +6,7 @@ struct UserProfileView: View {
66
77 let user: User
88 @State private var profileViewModel: UserProfileViewModel?
9 @State private var pinChangeCount = 0
910
1011 private static let iso8601Formatter: ISO8601DateFormatter = {
1112 let formatter = ISO8601DateFormatter()
@@ -14,6 +15,16 @@ struct UserProfileView: View {
1415 return formatter
1516 }()
1617
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
1728 var body: some View {
1829 List {
1930 if let avatarURL = user.avatar.flatMap(URL.init(string:)) {
@@ -164,6 +175,18 @@ struct UserProfileView: View {
164175 .listStyle(.insetGrouped)
165176 .navigationTitle(user.canonicalName)
166177 .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 }
167190 .task(id: user.canonicalName) {
168191 let owner = user.canonicalName.hasPrefix("~")
169192 ? String(user.canonicalName.dropFirst())
@@ -200,6 +223,12 @@ struct UserProfileView: View {
200223 }
201224 }
202225
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
203232 private func formattedTimestamp(_ value: String) -> String {
204233 guard let date = Self.iso8601Formatter.date(from: value) else {
205234 return value
Hutch/Views/Projects/ProjectMailingListView.swift +29
@@ -229,6 +229,17 @@ struct MailingListDetailView: View {
229229
230230 @Environment(AppState.self) private var appState
231231 @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 }
232243
233244 var body: some View {
234245 Group {
@@ -240,6 +251,18 @@ struct MailingListDetailView: View {
240251 }
241252 .navigationTitle(mailingList.name)
242253 .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 }
243266 .task {
244267 if viewModel == nil {
245268 let viewModel = MailingListDetailViewModel(
@@ -260,6 +283,12 @@ struct MailingListDetailView: View {
260283 }
261284 }
262285
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
263292 @ViewBuilder
264293 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
265294 @Bindable var vm = viewModel
Hutch/Views/Projects/ProjectPinStore.swift +29
@@ -5,6 +5,11 @@ enum ProjectPinStore {
55 for userKey: String,
66 defaults: UserDefaults = .standard
77 ) -> [String] {
8 let generalizedPins = HomePinStore.pinnedProjectIDs(for: userKey, defaults: defaults)
9 if !generalizedPins.isEmpty {
10 return generalizedPins
11 }
12
813 let pinnedProjects = loadAll(defaults: defaults)
914 return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
1015 }
@@ -22,6 +27,22 @@ enum ProjectPinStore {
2227 for userKey: String,
2328 defaults: UserDefaults = .standard
2429 ) {
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
2546 var pinnedProjects = loadAll(defaults: defaults)
2647 var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
2748
@@ -37,6 +58,14 @@ enum ProjectPinStore {
3758 save(pinnedProjects, defaults: defaults)
3859 }
3960
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
4069 private static func loadAll(defaults: UserDefaults) -> [String: [String]] {
4170 guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeProjects) else {
4271 return [:]
Hutch/Views/Repositories/HgRepositoryDetailView.swift +52 −11
@@ -19,12 +19,23 @@ struct HgRepositoryDetailView: View {
1919 @State private var showShareUnavailableAlert = false
2020 @State private var didCopyFileContents = false
2121 @State private var copyResetTask: Task<Void, Never>?
22 @State private var pinChangeCount = 0
2223
2324 private var canManageRepository: Bool {
2425 guard let currentUser = appState.currentUser else { return false }
2526 return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
2627 }
2728
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
2839 private var shareURL: URL? {
2940 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
3041 return SRHTWebURL.file(
@@ -58,17 +69,7 @@ struct HgRepositoryDetailView: View {
5869 }
5970 }
6071
61 SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
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 repositoryActionsMenu
7273 }
7374 }
7475 .sheet(isPresented: $showSettings) {
@@ -106,6 +107,46 @@ struct HgRepositoryDetailView: View {
106107 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
107108 }
108109
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
109150 @ViewBuilder
110151 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
111152 VStack(spacing: 0) {
Hutch/Views/Repositories/RepositoryDetailView.swift +38 −5
@@ -13,12 +13,23 @@ struct RepositoryDetailView: View {
1313 @State private var showSettings = false
1414 @State private var showACLs = false
1515 @State private var currentRepository: RepositorySummary
16 @State private var pinChangeCount = 0
1617
1718 private var canManageRepository: Bool {
1819 guard let currentUser = appState.currentUser else { return false }
1920 return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName)
2021 }
2122
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
2233 init(
2334 repository: RepositorySummary,
2435 onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
@@ -43,12 +54,8 @@ struct RepositoryDetailView: View {
4354 .navigationTitle(currentRepository.name)
4455 .navigationBarTitleDisplayMode(.inline)
4556 .toolbar {
46 ToolbarItemGroup(placement: .topBarTrailing) {
57 ToolbarItem(placement: .topBarTrailing) {
4758 repositoryActionsMenu
48
49 SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
50 Image(systemName: "square.and.arrow.up")
51 }
5259 }
5360 }
5461 .sheet(isPresented: $showSettings) {
@@ -82,6 +89,7 @@ struct RepositoryDetailView: View {
8289 client: appState.client
8390 )
8491 }
92 RecentActivityStore.recordRepository(currentRepository, defaults: appState.accountDefaults)
8593 }
8694 }
8795 }
@@ -130,6 +138,25 @@ struct RepositoryDetailView: View {
130138
131139 private var repositoryActionsMenu: some View {
132140 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
133160 if let repositoryURL = SRHTWebURL.repository(currentRepository) {
134161 Button {
135162 openURL(repositoryURL)
@@ -184,4 +211,10 @@ struct RepositoryDetailView: View {
184211 }
185212 .accessibilityLabel("Repository actions")
186213 }
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 }
187220}
Hutch/Views/Settings/SettingsView.swift +13 −1
@@ -264,10 +264,22 @@ private struct AboutView: View {
264264 get: { appState.isDebugModeEnabled },
265265 set: { appState.isDebugModeEnabled = $0 }
266266 ))
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 }
267279 } header: {
268280 Text("Developer")
269281 } 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.")
271283 }
272284 }
273285 }
Hutch/Views/SystemStatus/SystemStatusSummaryRow.swift +4 −6
@@ -63,7 +63,8 @@ struct SystemStatusSummaryRow: View {
6363
6464 private var primaryMessage: String {
6565 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)"
6768 }
6869 if let errorMessage, !errorMessage.isEmpty {
6970 return errorMessage
@@ -75,11 +76,8 @@ struct SystemStatusSummaryRow: View {
7576 }
7677
7778 private var metadataMessage: String? {
78 if let snapshot {
79 if isShowingStaleData {
80 return "Updated \(snapshot.lastUpdated.relativeDescription) • Showing saved data"
81 }
82 return "Updated \(snapshot.lastUpdated.relativeDescription)"
79 if snapshot != nil, isShowingStaleData {
80 return "Showing saved data"
8381 }
8482 if errorMessage != nil {
8583 return "Open System Status to retry."
Hutch/Views/Tickets/TicketDetailView.swift +9
@@ -220,6 +220,15 @@ struct TicketDetailView: View {
220220 commentInput(viewModel)
221221 }
222222 }
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 }
223232 .srhtErrorBanner(error: $vm.error)
224233 .refreshable {
225234 await reloadDetail(viewModel)
Hutch/Views/Tickets/TicketListView.swift +49 −15
@@ -23,12 +23,29 @@ struct TicketListView: View {
2323 @State private var showBulkCloseSheet = false
2424 @State private var showBulkAssignSheet = false
2525 @State private var bulkActionResult: TicketBulkActionResult?
26 @State private var pinChangeCount = 0
2627
2728 private var isOwnedByCurrentUser: Bool {
2829 guard let currentUser = appState.currentUser else { return false }
2930 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
3031 }
3132
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
3249 init(
3350 tracker: TrackerSummary,
3451 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in /* no-op: default for callers that don't handle this event */ },
@@ -47,7 +64,7 @@ struct TicketListView: View {
4764 SRHTLoadingStateView(message: "Loading tickets…")
4865 }
4966 }
50 .navigationTitle(tracker.name)
67 .navigationTitle(navigationTitle)
5168 .navigationBarTitleDisplayMode(.inline)
5269 .toolbar {
5370 ToolbarItem(placement: .topBarLeading) {
@@ -66,16 +83,6 @@ struct TicketListView: View {
6683 }
6784 .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
6885 } 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
7986 Button {
8087 showCreateTicketSheet = true
8188 } label: {
@@ -83,10 +90,6 @@ struct TicketListView: View {
8390 }
8491 .accessibilityLabel("Create ticket")
8592
86 Button("Select") {
87 viewModel?.setSelectionMode(true)
88 }
89
9093 trackerActionsMenu
9194 }
9295 }
@@ -242,6 +245,12 @@ struct TicketListView: View {
242245 }
243246 }
244247
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
245254 @ViewBuilder
246255 private func listContent(_ viewModel: TicketListViewModel) -> some View {
247256 @Bindable var vm = viewModel
@@ -429,6 +438,31 @@ struct TicketListView: View {
429438
430439 private var trackerActionsMenu: some View {
431440 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
432466 if let trackerURL = SRHTWebURL.tracker(tracker) {
433467 Button {
434468 openURL(trackerURL)
Hutch/Views/Work/WorkPrototypeView.swift added +7
@@ -0,0 +1,7 @@
1import SwiftUI
2
3struct WorkPrototypeView: View {
4 var body: some View {
5 WorkView()
6 }
7}
Hutch/Views/Work/WorkView.swift added +351
@@ -0,0 +1,351 @@
1import SwiftUI
2
3struct 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
262private 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
292private 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
326private 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
338private 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
2set -euo pipefail
3
4TRACKER="hutch"
5ERR_FILE="$(mktemp)"
6trap 'rm -f "$ERR_FILE"' EXIT
7
8label_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
26label_ticket 14 enhancement ui ux # Home: Collapsible Sections
27label_ticket 15 enhancement ui # Home: See All Navigation
28label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping
29
30label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle
31label_ticket 18 enhancement builds # Builds: Per-Repo Filter
32label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish
33
34label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions
35label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility
36
37label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes
38label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish
39
40label_ticket 24 new-feature theming ui # Theming: AMOLED Mode
41label_ticket 25 new-feature theming ui # Theming: High-Density Mode
42
43# v2.17.x
44label_ticket 26 new-feature projects # Projects: Project List View
45label_ticket 27 new-feature projects # Projects: Project Detail View
46label_ticket 28 enhancement projects ui # Projects: Pin to Home
47
48label_ticket 29 enhancement builds # Builds: Log Search
49label_ticket 30 enhancement builds # Builds: Jump to Error
50label_ticket 31 enhancement builds # Builds: Artifact List
51
52label_ticket 32 enhancement tickets # Tickets: Saved Filters
53label_ticket 33 enhancement tickets # Tickets: Label Filtering
54
55label_ticket 34 enhancement inbox # Inbox: Basic Threading
56label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs
57
58# v2.18.x
59label_ticket 36 new-feature projects # Projects: Create Project
60label_ticket 37 enhancement projects # Projects: Edit Project
61label_ticket 38 enhancement projects # Projects: Manage Linked Resources
62
63label_ticket 39 new-feature acl repo # ACL: View Permissions
64label_ticket 40 enhancement acl repo # ACL: Add and Remove Users
65label_ticket 41 enhancement acl repo # ACL: Edit Permissions
66
67label_ticket 42 enhancement repo # Repo Settings: Edit Metadata
68label_ticket 43 enhancement repo # Repo Settings: Set Default Branch
69label_ticket 44 enhancement repo # Repo Settings: Visibility Changes
70
71label_ticket 45 new-feature tickets # Tickets: Label CRUD
72label_ticket 46 enhancement tickets # Tickets: Bulk Actions
73
74# v2.19.x
75label_ticket 47 new-feature search # Search: Per-Type Search
76label_ticket 48 enhancement search # Search: Recent Searches
77
78label_ticket 49 new-feature accounts # Accounts: Multi-Account Support
79label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI
80label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching
81
82label_ticket 52 enhancement power # Power: Open in Browser
83label_ticket 53 enhancement power # Power: Copy Actions
84label_ticket 54 enhancement power # Power: Debug View Toggle
85
86# v2.20.x
87label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation
88
89label_ticket 56 enhancement performance # Performance: Caching Improvements
90label_ticket 57 enhancement performance # Performance: List Virtualization
91
92label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity
93
94label_ticket 59 bug api # Errors: Normalize GraphQL Handling
95
96echo "Done."