Commit 55f362e4c3

55f362e4c31d3dcc3e58f71125803fd2f29acdcf

parent: 6331339924

Unsigned

cmc <hello@cleberg.net> · 2026-04-02 04:25 UTC

feat: add Look Up screen and restrict management actions to owners

Implements: https://todo.sr.ht/~ccleberg/Hutch/4

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -539,7 +539,7 @@
539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541 CODE_SIGN_STYLE = Automatic; 541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 18; 542 CURRENT_PROJECT_VERSION = 19;
543 DEVELOPMENT_TEAM = ZCNAX3VL9D; 543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544 ENABLE_PREVIEWS = YES; 544 ENABLE_PREVIEWS = YES;
545 GENERATE_INFOPLIST_FILE = YES; 545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556 "$(inherited)", 556 "$(inherited)",
557 "@executable_path/Frameworks", 557 "@executable_path/Frameworks",
558 ); 558 );
559 MARKETING_VERSION = 2.7.1; 559 MARKETING_VERSION = 2.8.0;
560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561 PRODUCT_NAME = "$(TARGET_NAME)"; 561 PRODUCT_NAME = "$(TARGET_NAME)";
562 STRING_CATALOG_GENERATE_SYMBOLS = YES; 562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578 CODE_SIGN_STYLE = Automatic; 578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 18; 579 CURRENT_PROJECT_VERSION = 19;
580 DEVELOPMENT_TEAM = ZCNAX3VL9D; 580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581 ENABLE_PREVIEWS = YES; 581 ENABLE_PREVIEWS = YES;
582 GENERATE_INFOPLIST_FILE = YES; 582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593 "$(inherited)", 593 "$(inherited)",
594 "@executable_path/Frameworks", 594 "@executable_path/Frameworks",
595 ); 595 );
596 MARKETING_VERSION = 2.7.1; 596 MARKETING_VERSION = 2.8.0;
597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598 PRODUCT_NAME = "$(TARGET_NAME)"; 598 PRODUCT_NAME = "$(TARGET_NAME)";
599 STRING_CATALOG_GENERATE_SYMBOLS = YES; 599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656 APPLICATION_EXTENSION_API_ONLY = YES; 656 APPLICATION_EXTENSION_API_ONLY = YES;
657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658 CODE_SIGN_STYLE = Automatic; 658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 18; 659 CURRENT_PROJECT_VERSION = 19;
660 DEVELOPMENT_TEAM = ZCNAX3VL9D; 660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661 GENERATE_INFOPLIST_FILE = NO; 661 GENERATE_INFOPLIST_FILE = NO;
662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666 "@executable_path/Frameworks", 666 "@executable_path/Frameworks",
667 "@executable_path/../../Frameworks", 667 "@executable_path/../../Frameworks",
668 ); 668 );
669 MARKETING_VERSION = 2.7.1; 669 MARKETING_VERSION = 2.8.0;
670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671 PRODUCT_NAME = "$(TARGET_NAME)"; 671 PRODUCT_NAME = "$(TARGET_NAME)";
672 SKIP_INSTALL = YES; 672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685 APPLICATION_EXTENSION_API_ONLY = YES; 685 APPLICATION_EXTENSION_API_ONLY = YES;
686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687 CODE_SIGN_STYLE = Automatic; 687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 18; 688 CURRENT_PROJECT_VERSION = 19;
689 DEVELOPMENT_TEAM = ZCNAX3VL9D; 689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690 GENERATE_INFOPLIST_FILE = NO; 690 GENERATE_INFOPLIST_FILE = NO;
691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695 "@executable_path/Frameworks", 695 "@executable_path/Frameworks",
696 "@executable_path/../../Frameworks", 696 "@executable_path/../../Frameworks",
697 ); 697 );
698 MARKETING_VERSION = 2.7.1; 698 MARKETING_VERSION = 2.8.0;
699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700 PRODUCT_NAME = "$(TARGET_NAME)"; 700 PRODUCT_NAME = "$(TARGET_NAME)";
701 SKIP_INSTALL = YES; 701 SKIP_INSTALL = YES;
Hutch/App/RootView.swift +3
@@ -263,6 +263,7 @@ enum MoreDestination: Hashable {
263} 263}
264 264
265enum MoreRoute: Hashable { 265enum MoreRoute: Hashable {
266 case lookup
266 case lists 267 case lists
267 case pastes 268 case pastes
268 case settings 269 case settings
@@ -275,6 +276,8 @@ private struct MoreNavigationRoot: View {
275 MoreView() 276 MoreView()
276 .navigationDestination(for: MoreRoute.self) { route in 277 .navigationDestination(for: MoreRoute.self) { route in
277 switch route { 278 switch route {
279 case .lookup:
280 LookupView()
278 case .lists: 281 case .lists:
279 MailingListListView() 282 MailingListListView()
280 case .pastes: 283 case .pastes:
Hutch/Networking/SRHTClient.swift +10
@@ -98,6 +98,11 @@ final class SRHTClient: Sendable {
98 } 98 }
99 } 99 }
100 100
101 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
102 let errors = errorEnvelope.errors, !errors.isEmpty {
103 throw SRHTError.graphQLErrors(errors)
104 }
105
101 // Decode GraphQL response envelope 106 // Decode GraphQL response envelope
102 let graphQLResponse: GraphQLResponse<T> 107 let graphQLResponse: GraphQLResponse<T>
103 do { 108 do {
@@ -248,6 +253,11 @@ final class SRHTClient: Sendable {
248 } 253 }
249 } 254 }
250 255
256 if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
257 let errors = errorEnvelope.errors, !errors.isEmpty {
258 throw SRHTError.graphQLErrors(errors)
259 }
260
251 let graphQLResponse: GraphQLResponse<T> 261 let graphQLResponse: GraphQLResponse<T>
252 do { 262 do {
253 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) 263 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
Hutch/Views/Lookup/LookupView.swift added +387
@@ -0,0 +1,387 @@
1import SwiftUI
2
3enum LookupType: String, CaseIterable, Identifiable {
4 case user = "User"
5 case gitRepo = "Git Repo"
6 case hgRepo = "Hg Repo"
7 case mailingList = "Mailing List"
8 case tracker = "Tracker"
9 case buildJob = "Build Job"
10
11 var id: String { rawValue }
12
13 var placeholder: String {
14 switch self {
15 case .user:
16 "~username"
17 case .gitRepo, .hgRepo:
18 "~username/repo-name"
19 case .mailingList:
20 "~username/list-name"
21 case .tracker:
22 "~username/tracker-name"
23 case .buildJob:
24 "Job ID (e.g. 123456)"
25 }
26 }
27
28 var inputLabel: String {
29 switch self {
30 case .user:
31 "Username"
32 case .gitRepo, .hgRepo:
33 "Repository"
34 case .mailingList:
35 "Mailing List"
36 case .tracker:
37 "Tracker"
38 case .buildJob:
39 "Build Job ID"
40 }
41 }
42}
43
44enum LookupResult: Identifiable {
45 case user(User)
46 case repository(RepositorySummary)
47 case mailingList(InboxMailingListReference)
48 case tracker(TrackerSummary)
49 case buildJob(Int)
50
51 var id: String {
52 switch self {
53 case .user(let user):
54 "user:\(user.id)"
55 case .repository(let repository):
56 "repo:\(repository.id)"
57 case .mailingList(let mailingList):
58 "list:\(mailingList.id)"
59 case .tracker(let tracker):
60 "tracker:\(tracker.id)"
61 case .buildJob(let jobId):
62 "job:\(jobId)"
63 }
64 }
65}
66
67@Observable
68@MainActor
69final class LookupViewModel {
70 var selectedType: LookupType = .user
71 var inputText: String = ""
72 private(set) var result: LookupResult?
73 private(set) var isLooking = false
74 var error: String?
75
76 private let client: SRHTClient
77 private let appState: AppState
78
79 var resultBinding: Binding<LookupResult?> {
80 Binding(
81 get: { self.result },
82 set: { newValue in
83 if newValue == nil {
84 self.result = nil
85 }
86 }
87 )
88 }
89
90 init(client: SRHTClient, appState: AppState) {
91 self.client = client
92 self.appState = appState
93 }
94
95 func lookup() async {
96 result = nil
97 error = nil
98 isLooking = true
99 defer { isLooking = false }
100
101 do {
102 switch selectedType {
103 case .user:
104 result = try await lookupUser()
105 case .gitRepo:
106 result = try await lookupRepository(service: .git)
107 case .hgRepo:
108 result = try await lookupRepository(service: .hg)
109 case .mailingList:
110 result = try await lookupMailingList()
111 case .tracker:
112 result = try await lookupTracker()
113 case .buildJob:
114 result = try await lookupBuildJob()
115 }
116 } catch LookupError.invalidInput {
117 return
118 } catch {
119 self.error = error.userFacingMessage
120 }
121 }
122
123 private func parseOwnerAndName() -> (owner: String, name: String)? {
124 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
125 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
126 let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
127
128 guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
129 error = "Enter a value in the format ~username/name."
130 return nil
131 }
132
133 return (String(parts[0]), String(parts[1]))
134 }
135
136 private func parseUsername() -> String? {
137 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
138 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
139
140 guard !normalized.isEmpty else {
141 error = "Enter a username."
142 return nil
143 }
144
145 return normalized
146 }
147
148 private func parseBuildJobId() -> Int? {
149 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
150
151 guard let jobId = Int(trimmed) else {
152 error = "Enter a numeric build job ID."
153 return nil
154 }
155
156 return jobId
157 }
158
159 private func lookupUser() async throws -> LookupResult {
160 guard let username = parseUsername() else { throw LookupError.invalidInput }
161
162 struct Response: Decodable, Sendable {
163 let user: User
164 }
165
166 let query = """
167 query userLookup($username: String!) {
168 user: userByName(username: $username) {
169 id username canonicalName email avatar
170 }
171 }
172 """
173
174 let result = try await client.execute(
175 service: .meta,
176 query: query,
177 variables: ["username": username],
178 responseType: Response.self
179 )
180
181 return .user(result.user)
182 }
183
184 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
185 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
186
187 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
188 let resolvedRepository = RepositorySummary(
189 id: repository.id,
190 rid: repository.rid,
191 service: service,
192 name: repository.name,
193 description: repository.description,
194 visibility: repository.visibility,
195 updated: repository.updated,
196 owner: repository.owner,
197 head: repository.head
198 )
199
200 return .repository(resolvedRepository)
201 }
202
203 private func lookupMailingList() async throws -> LookupResult {
204 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
205
206 struct Response: Decodable, Sendable {
207 let user: UserWithList
208 }
209
210 struct UserWithList: Decodable, Sendable {
211 let mailingList: InboxMailingListReference
212 }
213
214 let query = """
215 query mailingListLookup($owner: String!, $name: String!) {
216 user(username: $owner) {
217 mailingList: list(name: $name) {
218 id rid name owner { canonicalName }
219 }
220 }
221 }
222 """
223
224 let result = try await client.execute(
225 service: .lists,
226 query: query,
227 variables: ["owner": owner, "name": name],
228 responseType: Response.self
229 )
230
231 return .mailingList(result.user.mailingList)
232 }
233
234 private func lookupTracker() async throws -> LookupResult {
235 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
236 let tracker = try await appState.resolveTracker(owner: owner, name: name)
237 return .tracker(tracker)
238 }
239
240 private func lookupBuildJob() async throws -> LookupResult {
241 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
242
243 struct Response: Decodable, Sendable {
244 let job: JobIdOnly
245 }
246
247 struct JobIdOnly: Decodable, Sendable {
248 let id: Int
249 }
250
251 let query = """
252 query buildLookup($id: Int!) {
253 job(id: $id) { id }
254 }
255 """
256
257 _ = try await client.execute(
258 service: .builds,
259 query: query,
260 variables: ["id": jobId],
261 responseType: Response.self
262 )
263
264 return .buildJob(jobId)
265 }
266
267 private enum LookupError: Error {
268 case invalidInput
269 }
270}
271
272struct LookupView: View {
273 @Environment(AppState.self) private var appState
274 @State private var viewModel: LookupViewModel?
275
276 var body: some View {
277 Group {
278 if let viewModel {
279 content(viewModel)
280 } else {
281 SRHTLoadingStateView(message: "Preparing lookup…")
282 }
283 }
284 .navigationTitle("Look Up")
285 .task {
286 if viewModel == nil {
287 viewModel = LookupViewModel(client: appState.client, appState: appState)
288 }
289 }
290 }
291
292 @ViewBuilder
293 private func content(_ viewModel: LookupViewModel) -> some View {
294 @Bindable var vm = viewModel
295
296 Form {
297 Section {
298 Picker("Type", selection: $vm.selectedType) {
299 ForEach(LookupType.allCases) { type in
300 Text(type.rawValue).tag(type)
301 }
302 }
303 .pickerStyle(.menu)
304
305 TextField(
306 vm.selectedType.inputLabel,
307 text: $vm.inputText,
308 prompt: Text(vm.selectedType.placeholder)
309 )
310 .textInputAutocapitalization(.never)
311 .autocorrectionDisabled()
312 .submitLabel(.search)
313 .onSubmit {
314 Task { await vm.lookup() }
315 }
316 }
317
318 Section {
319 HStack {
320 Button("Look Up") {
321 Task { await vm.lookup() }
322 }
323 .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
324
325 if vm.isLooking {
326 Spacer()
327 ProgressView()
328 }
329 }
330 }
331 }
332 .formStyle(.grouped)
333 .srhtErrorBanner(error: $vm.error)
334 .sheet(item: vm.resultBinding) { result in
335 NavigationStack {
336 lookupDestination(result)
337 }
338 .navigationDestination(for: MoreRoute.self) { route in
339 switch route {
340 case .lookup:
341 LookupView()
342 case .lists:
343 MailingListListView()
344 case .pastes:
345 PasteListView()
346 case .settings:
347 SettingsView()
348 case .mailingList(let mailingList):
349 MailingListDetailView(mailingList: mailingList)
350 case .thread(let thread):
351 ThreadDetailView(
352 thread: thread,
353 onViewed: {
354 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
355 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
356 },
357 onMarkRead: {
358 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
359 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
360 },
361 onMarkUnread: {
362 InboxReadStateStore.markUnread(for: thread.id)
363 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
364 }
365 )
366 }
367 }
368 .environment(appState)
369 }
370 }
371
372 @ViewBuilder
373 private func lookupDestination(_ result: LookupResult) -> some View {
374 switch result {
375 case .user(let user):
376 UserProfileView(user: user)
377 case .repository(let repository):
378 RepositoryDetailView(repository: repository)
379 case .mailingList(let mailingList):
380 MailingListDetailView(mailingList: mailingList)
381 case .tracker(let tracker):
382 TicketListView(tracker: tracker)
383 case .buildJob(let jobId):
384 BuildDetailView(jobId: jobId)
385 }
386 }
387}
Hutch/Views/Lookup/UserProfileView.swift added +52
@@ -0,0 +1,52 @@
1import SwiftUI
2
3struct UserProfileView: View {
4 let user: User
5
6 @Environment(AppState.self) private var appState
7
8 var body: some View {
9 List {
10 if let avatarURL = user.avatar.flatMap(URL.init(string:)) {
11 Section {
12 HStack {
13 Spacer()
14 AsyncImage(url: avatarURL) { phase in
15 switch phase {
16 case .success(let image):
17 image
18 .resizable()
19 .scaledToFill()
20 case .failure, .empty:
21 Image(systemName: "person.crop.circle.fill")
22 .resizable()
23 .scaledToFit()
24 .foregroundStyle(.secondary)
25 .padding(20)
26 @unknown default:
27 EmptyView()
28 }
29 }
30 .frame(width: 96, height: 96)
31 .clipShape(Circle())
32 .overlay {
33 Circle()
34 .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
35 }
36 Spacer()
37 }
38 .listRowBackground(Color.clear)
39 }
40 }
41
42 Section {
43 LabeledContent("Username", value: user.username)
44 LabeledContent("Canonical Name", value: user.canonicalName)
45 LabeledContent("Email", value: user.email)
46 }
47 }
48 .listStyle(.insetGrouped)
49 .navigationTitle(user.canonicalName)
50 .navigationBarTitleDisplayMode(.inline)
51 }
52}
Hutch/Views/More/MoreView.swift +4
@@ -14,6 +14,10 @@ struct MoreView: View {
14 var body: some View { 14 var body: some View {
15 List { 15 List {
16 Section { 16 Section {
17 NavigationLink(value: MoreRoute.lookup) {
18 Label("Look Up", systemImage: "magnifyingglass")
19 }
20
17 NavigationLink(value: MoreRoute.lists) { 21 NavigationLink(value: MoreRoute.lists) {
18 Label("Mailing Lists", systemImage: "list.bullet.rectangle") 22 Label("Mailing Lists", systemImage: "list.bullet.rectangle")
19 } 23 }
Hutch/Views/Projects/ProjectMailingListView.swift +17 −1
@@ -257,7 +257,23 @@ struct MailingListDetailView: View {
257 257
258 List { 258 List {
259 ForEach(viewModel.filteredThreads) { thread in 259 ForEach(viewModel.filteredThreads) { thread in
260 NavigationLink(value: MoreRoute.thread(thread)) { 260 NavigationLink {
261 ThreadDetailView(
262 thread: thread,
263 onViewed: {
264 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
265 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
266 },
267 onMarkRead: {
268 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
270 },
271 onMarkUnread: {
272 InboxReadStateStore.markUnread(for: thread.id)
273 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
274 }
275 )
276 } label: {
261 InboxThreadRow(thread: thread) 277 InboxThreadRow(thread: thread)
262 } 278 }
263 .swipeActions(edge: .trailing, allowsFullSwipe: true) { 279 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Hutch/Views/Repositories/HgRepositoryDetailView.swift +16 −4
@@ -14,6 +14,11 @@ struct HgRepositoryDetailView: View {
14 @State private var isShowingRepositoryDetails = false 14 @State private var isShowingRepositoryDetails = false
15 @State private var showBrowseRefPicker = false 15 @State private var showBrowseRefPicker = false
16 16
17 private var canManageRepository: Bool {
18 guard let currentUser = appState.currentUser else { return false }
19 return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
20 }
21
17 private var shareURL: URL? { 22 private var shareURL: URL? {
18 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } 23 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
19 return SRHTWebURL.file( 24 return SRHTWebURL.file(
@@ -51,10 +56,12 @@ struct HgRepositoryDetailView: View {
51 Image(systemName: "square.and.arrow.up") 56 Image(systemName: "square.and.arrow.up")
52 } 57 }
53 58
54 Button { 59 if canManageRepository {
55 showSettings = true 60 Button {
56 } label: { 61 showSettings = true
57 Image(systemName: "gear") 62 } label: {
63 Image(systemName: "gear")
64 }
58 } 65 }
59 } 66 }
60 } 67 }
@@ -85,6 +92,11 @@ struct HgRepositoryDetailView: View {
85 } 92 }
86 } 93 }
87 94
95 private func normalizedUsername(_ value: String) -> String {
96 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
97 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
98 }
99
88 @ViewBuilder 100 @ViewBuilder
89 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { 101 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
90 VStack(spacing: 0) { 102 VStack(spacing: 0) {
Hutch/Views/Repositories/RepositoryDetailView.swift +16 −4
@@ -11,6 +11,11 @@ struct RepositoryDetailView: View {
11 @State private var showSettings = false 11 @State private var showSettings = false
12 @State private var displayName: String 12 @State private var displayName: String
13 13
14 private var canManageRepository: Bool {
15 guard let currentUser = appState.currentUser else { return false }
16 return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
17 }
18
14 init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { 19 init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
15 self.repository = repository 20 self.repository = repository
16 self.onDeleted = onDeleted 21 self.onDeleted = onDeleted
@@ -36,10 +41,12 @@ struct RepositoryDetailView: View {
36 Image(systemName: "square.and.arrow.up") 41 Image(systemName: "square.and.arrow.up")
37 } 42 }
38 43
39 Button { 44 if canManageRepository {
40 showSettings = true 45 Button {
41 } label: { 46 showSettings = true
42 Image(systemName: "gear") 47 } label: {
48 Image(systemName: "gear")
49 }
43 } 50 }
44 } 51 }
45 } 52 }
@@ -104,4 +111,9 @@ struct RepositoryDetailView: View {
104 set: { viewModel.error = $0 } 111 set: { viewModel.error = $0 }
105 )) 112 ))
106 } 113 }
114
115 private func normalizedUsername(_ value: String) -> String {
116 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
117 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
118 }
107} 119}
Hutch/Views/Tickets/TicketDetailView.swift +14 −2
@@ -20,6 +20,11 @@ struct TicketDetailView: View {
20 // Comment composer mode 20 // Comment composer mode
21 @State private var commentMode: CommentMode = .write 21 @State private var commentMode: CommentMode = .write
22 22
23 private var isOwnedByCurrentUser: Bool {
24 guard let currentUser = appState.currentUser else { return false }
25 return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername)
26 }
27
23 private enum CommentMode: String, CaseIterable { 28 private enum CommentMode: String, CaseIterable {
24 case write = "Write" 29 case write = "Write"
25 case preview = "Preview" 30 case preview = "Preview"
@@ -41,7 +46,7 @@ struct TicketDetailView: View {
41 Image(systemName: "square.and.arrow.up") 46 Image(systemName: "square.and.arrow.up")
42 } 47 }
43 48
44 if let viewModel, viewModel.ticket != nil { 49 if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser {
45 actionsMenu(viewModel) 50 actionsMenu(viewModel)
46 } 51 }
47 } 52 }
@@ -195,7 +200,9 @@ struct TicketDetailView: View {
195 200
196 Spacer(minLength: 12) 201 Spacer(minLength: 12)
197 202
198 assignToMeButton(ticket: ticket, viewModel: viewModel) 203 if isOwnedByCurrentUser {
204 assignToMeButton(ticket: ticket, viewModel: viewModel)
205 }
199 } 206 }
200 207
201 HStack(spacing: 8) { 208 HStack(spacing: 8) {
@@ -334,6 +341,11 @@ struct TicketDetailView: View {
334 } 341 }
335 .padding() 342 .padding()
336 } 343 }
344
345 private func normalizedUsername(_ value: String) -> String {
346 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
347 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
348 }
337} 349}
338 350
339// MARK: - Self-Sizing Markdown Web View 351// MARK: - Self-Sizing Markdown Web View
Hutch/Views/Tickets/TicketListView.swift +8 −1
@@ -18,6 +18,11 @@ struct TicketListView: View {
18 @State private var showTrackerLabels = false 18 @State private var showTrackerLabels = false
19 @State private var showDeleteTrackerConfirmation = false 19 @State private var showDeleteTrackerConfirmation = false
20 20
21 private var isOwnedByCurrentUser: Bool {
22 guard let currentUser = appState.currentUser else { return false }
23 return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName)
24 }
25
21 init( 26 init(
22 tracker: TrackerSummary, 27 tracker: TrackerSummary,
23 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, 28 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in },
@@ -58,7 +63,9 @@ struct TicketListView: View {
58 } 63 }
59 .accessibilityLabel("Create ticket") 64 .accessibilityLabel("Create ticket")
60 65
61 trackerActionsMenu 66 if isOwnedByCurrentUser {
67 trackerActionsMenu
68 }
62 } 69 }
63 } 70 }
64 } 71 }