Commit b9ca700ee0
Unsigned
Layout: unified · split
Hutch/Extensions/SRHTShareUI.swift added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | enum 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 | |||
| 18 | struct 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 | |||
| 49 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // MARK: - Response types | ||
| 4 | |||
| 5 | private struct UpdateRepoResponse: Decodable, Sendable { | ||
| 6 | let updateRepository: UpdatedRepo | ||
| 7 | } | ||
| 8 | |||
| 9 | private 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 | |||
| 17 | private struct ACLResponse: Decodable, Sendable { | ||
| 18 | let repository: ACLRepository? | ||
| 19 | } | ||
| 20 | |||
| 21 | private struct ACLRepository: Decodable, Sendable { | ||
| 22 | let acls: ACLPage | ||
| 23 | } | ||
| 24 | |||
| 25 | private struct ACLPage: Decodable, Sendable { | ||
| 26 | let results: [ACLEntry] | ||
| 27 | let cursor: String? | ||
| 28 | } | ||
| 29 | |||
| 30 | private struct UpdateACLResponse: Decodable, Sendable { | ||
| 31 | let updateACL: ACLEntry | ||
| 32 | } | ||
| 33 | |||
| 34 | private struct DeleteACLResponse: Decodable, Sendable { | ||
| 35 | let deleteACL: DeletedACL | ||
| 36 | } | ||
| 37 | |||
| 38 | private struct DeletedACL: Decodable, Sendable { | ||
| 39 | let id: Int | ||
| 40 | } | ||
| 41 | |||
| 42 | private struct DeleteRepoResponse: Decodable, Sendable { | ||
| 43 | let deleteRepository: DeletedRepo | ||
| 44 | } | ||
| 45 | |||
| 46 | private struct DeletedRepo: Decodable, Sendable { | ||
| 47 | let id: Int | ||
| 48 | } | ||
| 49 | |||
| 50 | // MARK: - ACL Model | ||
| 51 | |||
| 52 | struct 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 | ||
| 62 | final 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct RepositoryCloneURLs { | ||
| 4 | let readOnly: String | ||
| 5 | let readWrite: String | ||
| 6 | } | ||
| 7 | |||
| 8 | func 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 | |||
| 31 | func 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 | |||
| 42 | struct 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 | |||
| 66 | struct 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 | |||
| 83 | struct 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 | |||
| 5 | Hutch 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 | |||
| 15 | The main risk now is not lack of scope. The main risk is shipping too late with | ||
| 16 | an increasingly broad surface area and not enough polish. | ||
| 17 | |||
| 18 | This roadmap treats the current app as a real 1.0 candidate and shifts the | ||
| 19 | focus from feature expansion to launch readiness. | ||
| 20 | |||
| 21 | ## Version 1.0 Goal | ||
| 22 | |||
| 23 | Ship a stable SourceHut mobile client with strong support for the most active | ||
| 24 | day-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 | |||
| 32 | Version 1.0 does not need to cover every sr.ht service. | ||
| 33 | |||
| 34 | ## Release Strategy | ||
| 35 | |||
| 36 | ### Phase 1: Ship Readiness | ||
| 37 | |||
| 38 | Focus 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 | |||
| 87 | Add one compact new service surface after launch. | ||
| 88 | |||
| 89 | #### Recommended priority | ||
| 90 | |||
| 91 | 1. Exact repository lookup | ||
| 92 | 2. paste.sr.ht | ||
| 93 | 3. 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 | |||
| 104 | Expand 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 | |||
| 114 | Hutch 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 | |||
| 121 | That means `lists.sr.ht`, `paste.sr.ht`, and `pages.sr.ht` are not blockers for | ||
| 122 | the first release. | ||
| 123 | |||
| 124 | ## Non-Blockers For 1.0 | ||
| 125 | |||
| 126 | These 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 | |||
| 135 | These 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 | |||
| 146 | Do not add another major service right away. | ||
| 147 | |||
| 148 | Instead: | ||
| 149 | |||
| 150 | 1. Run a release-focused polish pass | ||
| 151 | 2. Build a strict 1.0 checklist from the current app | ||
| 152 | 3. Ship to TestFlight or release publicly | ||
| 153 | 4. 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 | |||
| 3 | Hutch | ||
| 4 | Last updated: March 17, 2026 | ||
| 5 | |||
| 6 | ## Overview | ||
| 7 | |||
| 8 | Hutch is a client for SourceHut. This app allows users to browse repositories, | ||
| 9 | tickets, and builds using their own SourceHut account. | ||
| 10 | |||
| 11 | ## Data Collection | ||
| 12 | |||
| 13 | Hutch does not collect, store, or transmit personal data to the developer. | ||
| 14 | |||
| 15 | All data displayed in the app is retrieved directly from SourceHut using | ||
| 16 | user-provided authentication credentials. | ||
| 17 | |||
| 18 | ## Authentication | ||
| 19 | |||
| 20 | Hutch uses a SourceHut Personal Access Token (PAT) provided by the user. | ||
| 21 | |||
| 22 | The token is stored securely on-device and is used only to authenticate requests | ||
| 23 | to SourceHut. The developer does not have access to this token. | ||
| 24 | |||
| 25 | ## Data Usage | ||
| 26 | |||
| 27 | The app communicates with SourceHut servers to: | ||
| 28 | - fetch repositories, files, and commits | ||
| 29 | - retrieve and update tickets | ||
| 30 | - submit and monitor builds | ||
| 31 | |||
| 32 | No data is transmitted to any servers operated by the developer. | ||
| 33 | |||
| 34 | ## Third-Party Services | ||
| 35 | |||
| 36 | Hutch communicates with SourceHut services (sr.ht) to retrieve user data. | ||
| 37 | |||
| 38 | No analytics, advertising, or tracking SDKs are used. | ||
| 39 | |||
| 40 | ## User-Generated Content | ||
| 41 | |||
| 42 | The app displays content from SourceHut, including repositories, tickets, and | ||
| 43 | builds. This content is accessed based on the user’s authentication and | ||
| 44 | permissions. | ||
| 45 | |||
| 46 | ## Device Permissions | ||
| 47 | |||
| 48 | Hutch does not request access to sensitive device data such as location, | ||
| 49 | contacts, camera, or microphone. | ||
| 50 | |||
| 51 | ## Changes to This Policy | ||
| 52 | |||
| 53 | This policy may be updated in future versions. Updates will be reflected on this page. | ||
| 54 | |||
| 55 | ## Contact | ||
| 56 | |||
| 57 | For questions, contact: privacy@cleberg.net | ||