Commit b9ca700ee0

b9ca700ee0440f86d52b1865538b7983ecd26ad0

parent: 9039875a51

Unsigned

cmc <hello@cleberg.net> · 2026-03-18 04:19 UTC

create privacy policy

Layout: unified · split

Hutch/Extensions/SRHTShareUI.swift added +57
@@ -0,0 +1,57 @@
1import SwiftUI
2import UIKit
3
4enum SRHTShareTarget: String {
5 case repository = "repository"
6 case commit = "commit"
7 case file = "file"
8 case build = "build"
9 case tracker = "tracker"
10 case ticket = "ticket"
11 case profile = "profile"
12
13 var fallbackMessage: String {
14 "This \(rawValue) does not have a valid web URL to share."
15 }
16}
17
18struct SRHTShareButton<Label: View>: View {
19 let url: URL?
20 let target: SRHTShareTarget
21 @ViewBuilder let label: () -> Label
22
23 @State private var isShowingShareSheet = false
24 @State private var isShowingFallbackAlert = false
25
26 var body: some View {
27 Button {
28 if url != nil {
29 isShowingShareSheet = true
30 } else {
31 isShowingFallbackAlert = true
32 }
33 } label: {
34 label()
35 }
36 .sheet(isPresented: $isShowingShareSheet) {
37 if let url {
38 ShareSheet(activityItems: [url])
39 }
40 }
41 .alert("Share Unavailable", isPresented: $isShowingFallbackAlert) {
42 Button("OK", role: .cancel) {}
43 } message: {
44 Text(target.fallbackMessage)
45 }
46 }
47}
48
49private struct ShareSheet: UIViewControllerRepresentable {
50 let activityItems: [Any]
51
52 func makeUIViewController(context: Context) -> UIActivityViewController {
53 UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
54 }
55
56 func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
57}
Hutch/Extensions/SRHTWebURL.swift added +84
@@ -0,0 +1,84 @@
1import Foundation
2
3enum SRHTWebURL {
4 static func repository(_ repository: RepositorySummary) -> URL? {
5 userScopedURL(
6 host: "\(repository.service.rawValue).sr.ht",
7 ownerCanonicalName: repository.owner.canonicalName,
8 pathComponents: [repository.name]
9 )
10 }
11
12 static func build(jobId: Int, ownerCanonicalName: String) -> URL? {
13 userScopedURL(
14 host: "builds.sr.ht",
15 ownerCanonicalName: ownerCanonicalName,
16 pathComponents: ["job", String(jobId)]
17 )
18 }
19
20 static func tracker(ownerUsername: String, trackerName: String) -> URL? {
21 userScopedURL(
22 host: "todo.sr.ht",
23 ownerUsername: ownerUsername,
24 pathComponents: [trackerName]
25 )
26 }
27
28 static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? {
29 userScopedURL(
30 host: "todo.sr.ht",
31 ownerUsername: ownerUsername,
32 pathComponents: [trackerName, String(ticketId)]
33 )
34 }
35
36 static func profile(canonicalName: String) -> URL? {
37 userScopedURL(
38 host: "meta.sr.ht",
39 ownerCanonicalName: canonicalName,
40 pathComponents: []
41 )
42 }
43
44 private static func userScopedURL(
45 host: String,
46 ownerCanonicalName: String,
47 pathComponents: [String]
48 ) -> URL? {
49 userScopedURL(
50 host: host,
51 ownerUsername: username(from: ownerCanonicalName),
52 pathComponents: pathComponents
53 )
54 }
55
56 private static func userScopedURL(
57 host: String,
58 ownerUsername: String,
59 pathComponents: [String]
60 ) -> URL? {
61 var components = URLComponents()
62 components.scheme = "https"
63 components.host = host
64
65 let encodedComponents = (["~\(ownerUsername)"] + pathComponents).map { pathComponent in
66 pathComponent.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? pathComponent
67 }
68 components.percentEncodedPath = "/" + encodedComponents.joined(separator: "/")
69 return components.url
70 }
71
72 private static func username(from canonicalName: String) -> String {
73 if canonicalName.hasPrefix("~") {
74 return String(canonicalName.dropFirst())
75 }
76 return canonicalName
77 }
78
79 private static let pathComponentCharacterSet: CharacterSet = {
80 var characterSet = CharacterSet.urlPathAllowed
81 characterSet.remove(charactersIn: "/")
82 return characterSet
83 }()
84}
Hutch/Views/Repositories/HgRepositoryDetailView.swift added +419
@@ -0,0 +1,419 @@
1import SwiftUI
2
3struct HgRepositoryDetailView: View {
4 let repository: RepositorySummary
5 let onDeleted: (() -> Void)?
6
7 @Environment(AppState.self) private var appState
8 @Environment(\.dismiss) private var dismiss
9 @Environment(\.colorScheme) private var colorScheme
10
11 @State private var viewModel: HgRepositoryDetailViewModel?
12 @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
13 @State private var showSettings = false
14
15 var body: some View {
16 Group {
17 if let viewModel {
18 content(viewModel)
19 } else {
20 ProgressView()
21 }
22 }
23 .navigationTitle(repository.name)
24 .navigationBarTitleDisplayMode(.inline)
25 .toolbar {
26 ToolbarItem(placement: .topBarTrailing) {
27 Button {
28 showSettings = true
29 } label: {
30 Image(systemName: "gear")
31 }
32 }
33 }
34 .sheet(isPresented: $showSettings) {
35 HgRepositorySettingsView(
36 repository: repository,
37 client: appState.client,
38 onDeleted: {
39 dismiss()
40 onDeleted?()
41 }
42 )
43 }
44 .task {
45 if viewModel == nil {
46 let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
47 viewModel = vm
48 async let summary: () = vm.loadSummary()
49 async let browse: () = vm.loadBrowseRoot()
50 async let log: () = vm.loadLog()
51 _ = await (summary, browse, log)
52 }
53 }
54 }
55
56 @ViewBuilder
57 private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
58 VStack(spacing: 0) {
59 Picker("Tab", selection: $selectedTab) {
60 ForEach(HgRepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
61 Text(tab.rawValue).tag(tab)
62 }
63 }
64 .pickerStyle(.segmented)
65 .padding(.horizontal)
66 .padding(.vertical, 8)
67
68 Divider()
69
70 switch selectedTab {
71 case .summary:
72 summaryTab(viewModel)
73 case .browse:
74 browseTab(viewModel)
75 case .log:
76 logTab(viewModel)
77 case .tags:
78 revisionsList(viewModel.tags, emptyTitle: "No Tags", emptyDescription: "This repository does not have any tags.")
79 case .branches:
80 revisionsList(viewModel.branches, emptyTitle: "No Branches", emptyDescription: "This repository does not have any named branches.")
81 case .bookmarks:
82 revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
83 }
84 }
85 .alert("Error", isPresented: .constant(viewModel.error != nil)) {
86 Button("OK") { viewModel.error = nil }
87 } message: {
88 if let error = viewModel.error {
89 Text(error)
90 }
91 }
92 }
93
94 @ViewBuilder
95 private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
96 if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
97 ProgressView()
98 .frame(maxWidth: .infinity, maxHeight: .infinity)
99 } else {
100 ScrollView {
101 VStack(alignment: .leading, spacing: 16) {
102 summaryCards(viewModel)
103
104 if let readmeView = readmeContentView(viewModel) {
105 readmeView
106 } else {
107 ContentUnavailableView(
108 "No README",
109 systemImage: "doc.text",
110 description: Text("This repository does not have a README file.")
111 )
112 }
113 }
114 .padding()
115 }
116 .refreshable {
117 await viewModel.loadSummary()
118 }
119 }
120 }
121
122 private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View {
123 VStack(alignment: .leading, spacing: 12) {
124 LabeledContent("Visibility", value: visibilityLabel(repository.visibility))
125 LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
126
127 if let description = repository.description, !description.isEmpty {
128 VStack(alignment: .leading, spacing: 4) {
129 Text("Description")
130 .font(.caption.weight(.semibold))
131 .foregroundStyle(.secondary)
132 .textCase(.uppercase)
133 Text(description)
134 }
135 }
136
137 if let tip = viewModel.tip {
138 VStack(alignment: .leading, spacing: 6) {
139 Text("Tip")
140 .font(.caption.weight(.semibold))
141 .foregroundStyle(.secondary)
142 .textCase(.uppercase)
143 Text(tip.title)
144 .font(.headline)
145 HStack {
146 Text(tip.displayShortId)
147 .font(.caption.monospaced())
148 Spacer()
149 Text(tip.author.time.relativeDescription)
150 .font(.caption)
151 .foregroundStyle(.secondary)
152 }
153 Text(tip.author.name)
154 .font(.subheadline)
155 .foregroundStyle(.secondary)
156 }
157 }
158 }
159 .padding()
160 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
161 }
162
163 @ViewBuilder
164 private func browseTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
165 VStack(spacing: 0) {
166 browseBreadcrumbs(viewModel)
167 Divider()
168
169 if viewModel.isLoadingBrowse {
170 Spacer()
171 ProgressView()
172 Spacer()
173 } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
174 GeometryReader { geometry in
175 ScrollView([.vertical, .horizontal]) {
176 Text(fileContent)
177 .font(.system(.body, design: .monospaced))
178 .frame(
179 minWidth: geometry.size.width,
180 minHeight: geometry.size.height,
181 alignment: .topLeading
182 )
183 .padding()
184 }
185 }
186 .safeAreaInset(edge: .bottom) {
187 Button("Back to directory") {
188 viewModel.dismissFileView()
189 }
190 .buttonStyle(.bordered)
191 .padding(.vertical, 8)
192 .frame(maxWidth: .infinity)
193 .background(.bar)
194 }
195 .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
196 } else if viewModel.files.isEmpty {
197 ContentUnavailableView(
198 "No Files",
199 systemImage: "folder",
200 description: Text("This revision does not contain any browsable files.")
201 )
202 } else {
203 List(viewModel.files) { file in
204 Label {
205 Text(displayFileName(file.name))
206 .font(.body.monospaced())
207 } icon: {
208 Image(systemName: file.isDirectory ? "folder.fill" : "doc")
209 .foregroundStyle(file.isDirectory ? .blue : .secondary)
210 }
211 .contentShape(Rectangle())
212 .onTapGesture {
213 Task { await viewModel.openFile(file) }
214 }
215 }
216 .listStyle(.plain)
217 }
218 }
219 .refreshable {
220 await viewModel.loadBrowseRoot()
221 }
222 }
223
224 private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
225 ScrollView(.horizontal, showsIndicators: false) {
226 HStack(spacing: 4) {
227 Button {
228 Task { await viewModel.navigateToPath(index: 0) }
229 } label: {
230 Text("root")
231 .font(.subheadline.monospaced())
232 }
233 .buttonStyle(.plain)
234
235 ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
236 Image(systemName: "chevron.right")
237 .font(.caption2)
238 .foregroundStyle(.tertiary)
239
240 Button {
241 Task { await viewModel.navigateToPath(index: index + 1) }
242 } label: {
243 Text(component)
244 .font(.subheadline.monospaced())
245 .foregroundStyle(.secondary)
246 }
247 .buttonStyle(.plain)
248 }
249
250 if let selectedFilePath = viewModel.selectedFilePath {
251 Image(systemName: "chevron.right")
252 .font(.caption2)
253 .foregroundStyle(.tertiary)
254 Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
255 .font(.subheadline.monospaced())
256 }
257 }
258 .padding(.horizontal)
259 .padding(.vertical, 8)
260 }
261 .background(.bar)
262 }
263
264 @ViewBuilder
265 private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
266 List {
267 ForEach(viewModel.log) { revision in
268 revisionRow(revision)
269 .task {
270 await viewModel.loadMoreLogIfNeeded(currentItem: revision)
271 }
272 }
273
274 if viewModel.isLoadingMoreLog {
275 HStack {
276 Spacer()
277 ProgressView()
278 Spacer()
279 }
280 .listRowSeparator(.hidden)
281 }
282 }
283 .listStyle(.plain)
284 .overlay {
285 if viewModel.isLoadingLog {
286 ProgressView()
287 } else if viewModel.log.isEmpty {
288 ContentUnavailableView(
289 "No Revisions",
290 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
291 description: Text("This repository has no revision history.")
292 )
293 }
294 }
295 .refreshable {
296 await viewModel.loadLog()
297 }
298 }
299
300 @ViewBuilder
301 private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
302 if revisions.isEmpty {
303 ContentUnavailableView(
304 emptyTitle,
305 systemImage: "tray",
306 description: Text(emptyDescription)
307 )
308 } else {
309 List(revisions) { revision in
310 revisionRow(revision)
311 }
312 .listStyle(.plain)
313 }
314 }
315
316 private func revisionRow(_ revision: HgRevision) -> some View {
317 VStack(alignment: .leading, spacing: 6) {
318 HStack(alignment: .firstTextBaseline) {
319 Text(revision.primaryName)
320 .font(.headline)
321 Spacer()
322 Text(revision.displayShortId)
323 .font(.caption.monospaced())
324 .foregroundStyle(.secondary)
325 }
326
327 Text(revision.title)
328 .font(.subheadline)
329
330 if let body = revision.body {
331 Text(body)
332 .font(.caption)
333 .foregroundStyle(.secondary)
334 .lineLimit(2)
335 }
336
337 HStack {
338 Text(revision.author.name)
339 Spacer()
340 Text(revision.author.time.relativeDescription)
341 }
342 .font(.caption)
343 .foregroundStyle(.secondary)
344 }
345 .padding(.vertical, 4)
346 }
347
348 @ViewBuilder
349 private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
350 let imageURLResolver = makeImageURLResolver(viewModel)
351
352 guard let content = viewModel.readmeContent else {
353 return nil
354 }
355
356 switch content {
357 case .html(let html):
358 return AnyView(
359 HTMLWebView(html: html, colorScheme: colorScheme)
360 .frame(minHeight: 400)
361 )
362 case .markdown(let text):
363 return AnyView(
364 HTMLWebView(
365 html: markdownToHTML(text, imageURLResolver: imageURLResolver),
366 colorScheme: colorScheme
367 )
368 .frame(minHeight: 400)
369 )
370 case .org(let text):
371 return AnyView(
372 HTMLWebView(
373 html: orgToHTML(text, imageURLResolver: imageURLResolver),
374 colorScheme: colorScheme
375 )
376 .frame(minHeight: 400)
377 )
378 case .plainText(let text):
379 return AnyView(
380 Text(text)
381 .font(.system(.body, design: .monospaced))
382 .frame(maxWidth: .infinity, alignment: .leading)
383 .padding()
384 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
385 )
386 }
387 }
388
389 private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? {
390 let owner = repository.owner.canonicalName
391 let repositoryName = repository.name
392 let readmePath = viewModel.readmePath
393
394 return { source in
395 resolveRepositoryAssetURL(
396 source,
397 owner: owner,
398 repositoryName: repositoryName,
399 readmePath: readmePath
400 )?
401 .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht")
402 }
403 }
404
405 private func displayFileName(_ name: String) -> String {
406 name.hasSuffix("/") ? String(name.dropLast()) : name
407 }
408
409 private func visibilityLabel(_ visibility: Visibility) -> String {
410 switch visibility {
411 case .public:
412 return "Public"
413 case .unlisted:
414 return "Unlisted"
415 case .private:
416 return "Private"
417 }
418 }
419}
Hutch/Views/Repositories/RepositorySettingsView.swift added +235
@@ -0,0 +1,235 @@
1import SwiftUI
2
3struct RepositorySettingsView: View {
4 let repository: RepositorySummary
5 let branches: [Reference]
6 let client: SRHTClient
7 let onRenamed: (String) -> Void
8 let onDeleted: () -> Void
9
10 @Environment(\.dismiss) private var dismiss
11 @State private var viewModel: RepositorySettingsViewModel?
12 @State private var showDeleteConfirmation = false
13
14 var body: some View {
15 NavigationStack {
16 Group {
17 if let viewModel {
18 settingsForm(viewModel)
19 } else {
20 ProgressView()
21 }
22 }
23 .navigationTitle("Settings")
24 .navigationBarTitleDisplayMode(.inline)
25 .toolbar {
26 ToolbarItem(placement: .cancellationAction) {
27 Button("Done") { dismiss() }
28 }
29 }
30 }
31 .task {
32 if viewModel == nil {
33 let vm = RepositorySettingsViewModel(
34 repository: repository,
35 branches: branches,
36 client: client
37 )
38 viewModel = vm
39 await vm.loadACLs()
40 }
41 }
42 }
43
44 @ViewBuilder
45 private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
46 @Bindable var vm = viewModel
47
48 Form {
49 infoSection(viewModel)
50 renameSection(viewModel)
51 accessSection(viewModel)
52 deleteSection(viewModel)
53 }
54 .alert("Error", isPresented: .constant(viewModel.error != nil)) {
55 Button("OK") { viewModel.error = nil }
56 } message: {
57 if let error = viewModel.error {
58 Text(error)
59 }
60 }
61 .alert(
62 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
63 isPresented: $showDeleteConfirmation
64 ) {
65 Button("Cancel", role: .cancel) {}
66 Button("Delete", role: .destructive) {
67 Task {
68 await viewModel.deleteRepository()
69 if viewModel.didDelete {
70 dismiss()
71 onDeleted()
72 }
73 }
74 }
75 } message: {
76 Text("This cannot be undone.")
77 }
78 }
79
80 // MARK: - Info Section
81
82 @ViewBuilder
83 private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
84 Section("Info") {
85 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
86 .lineLimit(3...6)
87
88 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
89 Text("Public").tag(Visibility.public)
90 Text("Unlisted").tag(Visibility.unlisted)
91 Text("Private").tag(Visibility.private)
92 }
93
94 if !viewModel.branches.isEmpty {
95 Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
96 ForEach(viewModel.branches, id: \.name) { branch in
97 let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
98 Text(name).tag(name)
99 }
100 }
101 }
102
103 Button {
104 Task { await viewModel.saveInfo() }
105 } label: {
106 if viewModel.isSavingInfo {
107 ProgressView()
108 .frame(maxWidth: .infinity)
109 } else {
110 Text("Save")
111 .frame(maxWidth: .infinity)
112 }
113 }
114 .disabled(viewModel.isSavingInfo)
115 }
116 }
117
118 // MARK: - Rename Section
119
120 @ViewBuilder
121 private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
122 Section {
123 TextField("Repository Name", text: Bindable(viewModel).editedName)
124 .autocorrectionDisabled()
125 .textInputAutocapitalization(.never)
126
127 Text("This will change the repository URL. Existing clones will be redirected but links may break.")
128 .font(.caption)
129 .foregroundStyle(.secondary)
130
131 Button {
132 Task {
133 await viewModel.rename()
134 if let newName = viewModel.updatedName {
135 onRenamed(newName)
136 dismiss()
137 }
138 }
139 } label: {
140 if viewModel.isRenaming {
141 ProgressView()
142 .frame(maxWidth: .infinity)
143 } else {
144 Text("Rename")
145 .frame(maxWidth: .infinity)
146 }
147 }
148 .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
149 } header: {
150 Text("Rename")
151 }
152 }
153
154 // MARK: - Access Section
155
156 @ViewBuilder
157 private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
158 Section {
159 if viewModel.isLoadingACLs {
160 HStack {
161 Spacer()
162 ProgressView()
163 Spacer()
164 }
165 } else if viewModel.acls.isEmpty {
166 Text("No access control entries.")
167 .foregroundStyle(.secondary)
168 } else {
169 ForEach(viewModel.acls) { entry in
170 HStack {
171 Text(entry.entity.canonicalName)
172 Spacer()
173 Text(entry.mode)
174 .font(.caption.monospaced())
175 .foregroundStyle(.secondary)
176 }
177 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
178 Button(role: .destructive) {
179 Task { await viewModel.deleteACL(entry) }
180 } label: {
181 Label("Delete", systemImage: "trash")
182 }
183 }
184 }
185 }
186
187 // Add ACL form
188 HStack {
189 TextField("Username", text: Bindable(viewModel).newACLEntity)
190 .autocorrectionDisabled()
191 .textInputAutocapitalization(.never)
192
193 Picker("", selection: Bindable(viewModel).newACLMode) {
194 Text("RO").tag("RO")
195 Text("RW").tag("RW")
196 }
197 .pickerStyle(.segmented)
198 .frame(width: 100)
199
200 Button {
201 Task { await viewModel.addACL() }
202 } label: {
203 if viewModel.isAddingACL {
204 ProgressView()
205 } else {
206 Image(systemName: "plus.circle.fill")
207 }
208 }
209 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
210 }
211 } header: {
212 Text("Access")
213 }
214 }
215
216 // MARK: - Delete Section
217
218 @ViewBuilder
219 private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
220 Section {
221 Button(role: .destructive) {
222 showDeleteConfirmation = true
223 } label: {
224 if viewModel.isDeleting {
225 ProgressView()
226 .frame(maxWidth: .infinity)
227 } else {
228 Text("Delete Repository")
229 .frame(maxWidth: .infinity)
230 }
231 }
232 .disabled(viewModel.isDeleting)
233 }
234 }
235}
Hutch/Views/Repositories/RepositorySettingsViewModel.swift added +301
@@ -0,0 +1,301 @@
1import Foundation
2
3// MARK: - Response types
4
5private struct UpdateRepoResponse: Decodable, Sendable {
6 let updateRepository: UpdatedRepo
7}
8
9private struct UpdatedRepo: Decodable, Sendable {
10 let id: Int
11 let rid: String
12 let name: String
13 let description: String?
14 let visibility: Visibility
15}
16
17private struct ACLResponse: Decodable, Sendable {
18 let repository: ACLRepository?
19}
20
21private struct ACLRepository: Decodable, Sendable {
22 let acls: ACLPage
23}
24
25private struct ACLPage: Decodable, Sendable {
26 let results: [ACLEntry]
27 let cursor: String?
28}
29
30private struct UpdateACLResponse: Decodable, Sendable {
31 let updateACL: ACLEntry
32}
33
34private struct DeleteACLResponse: Decodable, Sendable {
35 let deleteACL: DeletedACL
36}
37
38private struct DeletedACL: Decodable, Sendable {
39 let id: Int
40}
41
42private struct DeleteRepoResponse: Decodable, Sendable {
43 let deleteRepository: DeletedRepo
44}
45
46private struct DeletedRepo: Decodable, Sendable {
47 let id: Int
48}
49
50// MARK: - ACL Model
51
52struct ACLEntry: Decodable, Sendable, Identifiable {
53 let id: Int
54 let mode: String
55 let entity: Entity
56}
57
58// MARK: - View Model
59
60@Observable
61@MainActor
62final class RepositorySettingsViewModel {
63
64 let repositoryId: Int
65 let repositoryRid: String
66 private let client: SRHTClient
67
68 // MARK: - Info fields
69
70 var editedDescription: String
71 var editedVisibility: Visibility
72 var editedHead: String
73 var isSavingInfo = false
74
75 // MARK: - Rename fields
76
77 var editedName: String
78 var isRenaming = false
79
80 // MARK: - ACL state
81
82 private(set) var acls: [ACLEntry] = []
83 private(set) var isLoadingACLs = false
84 var newACLEntity = ""
85 var newACLMode = "RO"
86 var isAddingACL = false
87 var isDeletingACL = false
88
89 // MARK: - Delete state
90
91 var isDeleting = false
92
93 // MARK: - Branches (for HEAD picker)
94
95 var branches: [Reference]
96
97 // MARK: - Results
98
99 var error: String?
100 var updatedName: String?
101 var didDelete = false
102
103 init(
104 repository: RepositorySummary,
105 branches: [Reference],
106 client: SRHTClient
107 ) {
108 self.repositoryId = repository.id
109 self.repositoryRid = repository.rid
110 self.client = client
111 self.editedDescription = repository.description ?? ""
112 self.editedVisibility = repository.visibility
113 self.editedName = repository.name
114 self.branches = branches
115
116 // Extract branch name from HEAD reference
117 if let head = repository.head?.name {
118 self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
119 } else {
120 self.editedHead = "main"
121 }
122 }
123
124 // MARK: - Update Repository Info
125
126 private static let updateRepoMutation = """
127 mutation updateRepository($id: Int!, $input: RepoInput!) {
128 updateRepository(id: $id, input: $input) {
129 id rid name description visibility
130 }
131 }
132 """
133
134 func saveInfo() async {
135 isSavingInfo = true
136 defer { isSavingInfo = false }
137 error = nil
138
139 do {
140 let input: [String: any Sendable] = [
141 "description": editedDescription,
142 "visibility": editedVisibility.rawValue,
143 "HEAD": editedHead
144 ]
145 _ = try await client.execute(
146 service: .git,
147 query: Self.updateRepoMutation,
148 variables: ["id": repositoryId, "input": input],
149 responseType: UpdateRepoResponse.self
150 )
151 } catch {
152 self.error = error.localizedDescription
153 }
154 }
155
156 // MARK: - Rename
157
158 func rename() async {
159 isRenaming = true
160 defer { isRenaming = false }
161 error = nil
162
163 do {
164 let input: [String: any Sendable] = [
165 "name": editedName
166 ]
167 let result = try await client.execute(
168 service: .git,
169 query: Self.updateRepoMutation,
170 variables: ["id": repositoryId, "input": input],
171 responseType: UpdateRepoResponse.self
172 )
173 updatedName = result.updateRepository.name
174 } catch {
175 self.error = error.localizedDescription
176 }
177 }
178
179 // MARK: - ACLs
180
181 private static let aclsQuery = """
182 query acls($rid: ID!) {
183 repository(rid: $rid) {
184 acls {
185 results {
186 id
187 mode
188 entity { canonicalName }
189 }
190 cursor
191 }
192 }
193 }
194 """
195
196 private static let updateACLMutation = """
197 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
198 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
199 id mode entity { canonicalName }
200 }
201 }
202 """
203
204 private static let deleteACLMutation = """
205 mutation deleteACL($id: Int!) {
206 deleteACL(id: $id) { id }
207 }
208 """
209
210 func loadACLs() async {
211 guard !isLoadingACLs else { return }
212 isLoadingACLs = true
213 defer { isLoadingACLs = false }
214
215 do {
216 let result = try await client.execute(
217 service: .git,
218 query: Self.aclsQuery,
219 variables: ["rid": repositoryRid],
220 responseType: ACLResponse.self
221 )
222 acls = result.repository?.acls.results ?? []
223 } catch {
224 self.error = error.localizedDescription
225 }
226 }
227
228 func addACL() async {
229 let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
230 guard !entity.isEmpty else { return }
231 isAddingACL = true
232 defer { isAddingACL = false }
233 error = nil
234
235 do {
236 let result = try await client.execute(
237 service: .git,
238 query: Self.updateACLMutation,
239 variables: [
240 "repoId": repositoryId,
241 "mode": newACLMode,
242 "entity": entity
243 ],
244 responseType: UpdateACLResponse.self
245 )
246 // Replace existing entry or append
247 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
248 acls[index] = result.updateACL
249 } else {
250 acls.append(result.updateACL)
251 }
252 newACLEntity = ""
253 } catch {
254 self.error = error.localizedDescription
255 }
256 }
257
258 func deleteACL(_ entry: ACLEntry) async {
259 isDeletingACL = true
260 defer { isDeletingACL = false }
261 error = nil
262
263 do {
264 _ = try await client.execute(
265 service: .git,
266 query: Self.deleteACLMutation,
267 variables: ["id": entry.id],
268 responseType: DeleteACLResponse.self
269 )
270 acls.removeAll { $0.id == entry.id }
271 } catch {
272 self.error = error.localizedDescription
273 }
274 }
275
276 // MARK: - Delete Repository
277
278 private static let deleteRepoMutation = """
279 mutation deleteRepository($id: Int!) {
280 deleteRepository(id: $id) { id }
281 }
282 """
283
284 func deleteRepository() async {
285 isDeleting = true
286 defer { isDeleting = false }
287 error = nil
288
289 do {
290 _ = try await client.execute(
291 service: .git,
292 query: Self.deleteRepoMutation,
293 variables: ["id": repositoryId],
294 responseType: DeleteRepoResponse.self
295 )
296 didDelete = true
297 } catch {
298 self.error = error.localizedDescription
299 }
300 }
301}
Hutch/Views/Repositories/RepositorySummarySupport.swift added +101
@@ -0,0 +1,101 @@
1import SwiftUI
2
3struct RepositoryCloneURLs {
4 let readOnly: String
5 let readWrite: String
6}
7
8func repositoryCloneURLs(for repository: RepositorySummary) -> RepositoryCloneURLs {
9 let owner = repository.owner.canonicalName
10 let name = repository.name
11
12 switch repository.service {
13 case .git:
14 return RepositoryCloneURLs(
15 readOnly: "https://git.sr.ht/\(owner)/\(name)",
16 readWrite: "git@git.sr.ht:\(owner)/\(name)"
17 )
18 case .hg:
19 return RepositoryCloneURLs(
20 readOnly: "https://hg.sr.ht/\(owner)/\(name)",
21 readWrite: "ssh://hg@hg.sr.ht/\(owner)/\(name)"
22 )
23 default:
24 return RepositoryCloneURLs(
25 readOnly: "https://\(repository.service.rawValue).sr.ht/\(owner)/\(name)",
26 readWrite: ""
27 )
28 }
29}
30
31func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
32 switch visibility {
33 case .public:
34 return "Public"
35 case .unlisted:
36 return "Unlisted"
37 case .private:
38 return "Private"
39 }
40}
41
42struct RepositorySummaryCard<Content: View>: View {
43 let title: String
44 @ViewBuilder let content: Content
45
46 init(_ title: String, @ViewBuilder content: () -> Content) {
47 self.title = title
48 self.content = content()
49 }
50
51 var body: some View {
52 VStack(alignment: .leading, spacing: 12) {
53 Text(title)
54 .font(.caption.weight(.semibold))
55 .foregroundStyle(.secondary)
56 .textCase(.uppercase)
57
58 content
59 }
60 .frame(maxWidth: .infinity, alignment: .leading)
61 .padding()
62 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
63 }
64}
65
66struct RepositorySummaryField: View {
67 let label: String
68 let value: String
69 var monospace: Bool = false
70
71 var body: some View {
72 VStack(alignment: .leading, spacing: 4) {
73 Text(label)
74 .font(.caption)
75 .foregroundStyle(.secondary)
76 Text(value)
77 .font(monospace ? .system(.body, design: .monospaced) : .body)
78 .textSelection(.enabled)
79 }
80 }
81}
82
83struct RepositorySummaryListRow: View {
84 let label: String
85 let values: [String]
86
87 var body: some View {
88 VStack(alignment: .leading, spacing: 4) {
89 Text(label)
90 .font(.caption)
91 .foregroundStyle(.secondary)
92
93 if values.isEmpty {
94 Text("None")
95 .foregroundStyle(.tertiary)
96 } else {
97 Text(values.joined(separator: ", "))
98 }
99 }
100 }
101}
ROADMAP.md added +155
@@ -0,0 +1,155 @@
1# Hutch Roadmap
2
3## Positioning
4
5Hutch is already beyond a minimal v1. The core SourceHut surfaces are present:
6
7- Authentication with PAT flow
8- Git repositories
9- Mercurial repositories
10- Trackers and tickets
11- Builds
12- Sharing
13- Core settings and app metadata
14
15The main risk now is not lack of scope. The main risk is shipping too late with
16an increasingly broad surface area and not enough polish.
17
18This roadmap treats the current app as a real 1.0 candidate and shifts the
19focus from feature expansion to launch readiness.
20
21## Version 1.0 Goal
22
23Ship a stable SourceHut mobile client with strong support for the most active
24day-to-day workflows:
25
26- Browse and manage repositories
27- Browse and manage tickets
28- Browse and manage builds
29- Support both git and hg repositories
30- Provide dependable sharing and navigation
31
32Version 1.0 does not need to cover every sr.ht service.
33
34## Release Strategy
35
36### Phase 1: Ship Readiness
37
38Focus on quality, not new surface area.
39
40#### Core release checklist
41
42- Verify authentication flow on simulator and physical device
43- Verify reset/sign-out/reset-app-data flow on simulator and physical device
44- Verify all destructive actions have confirmation and correct follow-up state
45- Verify all create flows succeed end-to-end:
46 - Git repository
47 - Mercurial repository
48 - Tracker
49 - Ticket
50 - Build submission
51- Verify build retry and edit/resubmit flows
52- Verify repository sharing links for:
53 - repository
54 - commit
55 - file
56- Verify sharing links for:
57 - build
58 - tracker
59 - ticket
60 - profile
61- Review empty/loading/error states across all main tabs
62- Remove any leftover temporary debug logging
63- Audit device-only issues:
64 - Xcode attach quirks
65 - on-device auth/cache behavior
66 - WebView rendering performance
67
68#### UI/UX polish checklist
69
70- Tighten wording and error messages across create/edit flows
71- Confirm summary tabs feel consistent across git and hg
72- Confirm toolbar actions are visible and non-duplicated
73- Confirm README rendering is smooth on large repositories
74- Confirm forms behave well on iPhone-sized screens
75- Confirm keyboard behavior and dismissal in all creation sheets
76
77#### App Store readiness
78
79- Finalize app icon and screenshots
80- Finalize App Store copy
81- Finalize privacy details
82- Finalize support URL / project URL
83- Decide whether TestFlight comes before public launch
84
85### Phase 2: Version 1.1
86
87Add one compact new service surface after launch.
88
89#### Recommended priority
90
911. Exact repository lookup
922. paste.sr.ht
933. lists.sr.ht
94
95#### Why
96
97- Exact repository lookup solves a real gap created by the lack of public
98 discovery APIs
99- paste.sr.ht is relatively self-contained
100- lists.sr.ht is valuable, but broader in UI and data model scope
101
102### Phase 3: Version 1.2+
103
104Expand only after the v1 core is stable in the wild.
105
106- lists.sr.ht tab
107- paste.sr.ht creation/editing polish
108- pages.sr.ht management
109- broader deep-link/share coverage
110- workflow refinements for builds and tickets
111
112## What Counts As “Done Enough” For 1.0
113
114Hutch is ready for 1.0 when:
115
116- The main flows work reliably on real devices
117- Errors are understandable
118- The app does not feel inconsistent across git/hg/builds/tickets
119- The missing services feel like roadmap items, not broken gaps
120
121That means `lists.sr.ht`, `paste.sr.ht`, and `pages.sr.ht` are not blockers for
122the first release.
123
124## Non-Blockers For 1.0
125
126These should not delay launch:
127
128- Donation page / IAP
129- Broad service parity across all SourceHut products
130- Public repo discovery equivalent to `sr.ht/projects`
131- Advanced creation workflows beyond what the public APIs cleanly support
132
133## Open Product Questions
134
135These are worth deciding before or shortly after launch:
136
137- Should the app be positioned as “SourceHut client” or “SourceHut for git/hg,
138 tickets, and builds” in App Store messaging?
139- Should exact repository lookup live in Repositories search, a dedicated sheet,
140 or both?
141- Should `paste.sr.ht` or `lists.sr.ht` be the first new post-launch tab?
142- Is TestFlight feedback needed before calling the first public build 1.0?
143
144## Recommended Next Step
145
146Do not add another major service right away.
147
148Instead:
149
1501. Run a release-focused polish pass
1512. Build a strict 1.0 checklist from the current app
1523. Ship to TestFlight or release publicly
1534. Use `ROADMAP.md` and `TODO.md` separately:
154 - `ROADMAP.md` for release strategy
155 - `TODO.md` for concrete implementation backlog
privacypolicy.md added +57
@@ -0,0 +1,57 @@
1# Privacy Policy
2
3Hutch
4Last updated: March 17, 2026
5
6## Overview
7
8Hutch is a client for SourceHut. This app allows users to browse repositories,
9tickets, and builds using their own SourceHut account.
10
11## Data Collection
12
13Hutch does not collect, store, or transmit personal data to the developer.
14
15All data displayed in the app is retrieved directly from SourceHut using
16user-provided authentication credentials.
17
18## Authentication
19
20Hutch uses a SourceHut Personal Access Token (PAT) provided by the user.
21
22The token is stored securely on-device and is used only to authenticate requests
23to SourceHut. The developer does not have access to this token.
24
25## Data Usage
26
27The app communicates with SourceHut servers to:
28- fetch repositories, files, and commits
29- retrieve and update tickets
30- submit and monitor builds
31
32No data is transmitted to any servers operated by the developer.
33
34## Third-Party Services
35
36Hutch communicates with SourceHut services (sr.ht) to retrieve user data.
37
38No analytics, advertising, or tracking SDKs are used.
39
40## User-Generated Content
41
42The app displays content from SourceHut, including repositories, tickets, and
43builds. This content is accessed based on the user’s authentication and
44permissions.
45
46## Device Permissions
47
48Hutch does not request access to sensitive device data such as location,
49contacts, camera, or microphone.
50
51## Changes to This Policy
52
53This policy may be updated in future versions. Updates will be reflected on this page.
54
55## Contact
56
57For questions, contact: privacy@cleberg.net