krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Repositories/RepositoryDetailView.swift · raw
1import SwiftUI
2
3struct RepositoryDetailView: View {
4 @Environment(\.openURL) private var openURL
5
6 let onRepositoryUpdated: ((RepositorySummary) -> Void)?
7 var onDeleted: (() -> Void)?
8
9 @Environment(AppState.self) private var appState
10 @Environment(\.dismiss) private var dismiss
11 @State private var viewModel: RepositoryDetailViewModel?
12 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
13 @State private var showSettings = false
14 @State private var showACLs = false
15 @State private var showDeployKeys = false
16 @State private var currentRepository: RepositorySummary
17 @State private var pinChangeCount = 0
18
19 private var canManageRepository: Bool {
20 guard let currentUser = appState.currentUser else { return false }
21 return normalizedUsername(currentUser.username) == normalizedUsername(currentRepository.owner.canonicalName)
22 }
23
24 private var currentUserKey: String? {
25 appState.currentUser?.canonicalName
26 }
27
28 private var isPinnedToHome: Bool {
29 _ = pinChangeCount
30 guard let currentUserKey else { return false }
31 return HomePinStore.isPinned(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults)
32 }
33
34 init(
35 repository: RepositorySummary,
36 onRepositoryUpdated: ((RepositorySummary) -> Void)? = nil,
37 onDeleted: (() -> Void)? = nil
38 ) {
39 self.onRepositoryUpdated = onRepositoryUpdated
40 self.onDeleted = onDeleted
41 self._currentRepository = State(initialValue: repository)
42 }
43
44 var body: some View {
45 if currentRepository.service == .hg {
46 HgRepositoryDetailView(repository: currentRepository, onDeleted: onDeleted)
47 } else {
48 Group {
49 if let viewModel {
50 detailContent(viewModel)
51 } else {
52 SRHTLoadingStateView(message: "Loading repository…")
53 }
54 }
55 .navigationTitle(currentRepository.name)
56 .navigationBarTitleDisplayMode(.inline)
57 .toolbar {
58 ToolbarItem(placement: .topBarTrailing) {
59 repositoryActionsMenu
60 }
61 }
62 .sheet(isPresented: $showSettings) {
63 RepositorySettingsView(
64 repository: currentRepository,
65 branches: viewModel?.branches ?? [],
66 client: appState.client,
67 onUpdated: { updatedRepository in
68 currentRepository = updatedRepository
69 onRepositoryUpdated?(updatedRepository)
70 },
71 onDeleted: {
72 dismiss()
73 onDeleted?()
74 }
75 )
76 }
77 .sheet(isPresented: $showACLs) {
78 NavigationStack {
79 RepositoryACLView(
80 repository: currentRepository,
81 client: appState.client,
82 showsDoneButton: true
83 )
84 }
85 }
86 .sheet(isPresented: $showDeployKeys) {
87 NavigationStack {
88 RepositoryDeployKeysView(
89 repository: currentRepository,
90 client: appState.client,
91 showsDoneButton: true
92 )
93 }
94 }
95 .task {
96 if viewModel == nil {
97 viewModel = RepositoryDetailViewModel(
98 repository: currentRepository,
99 client: appState.client
100 )
101 }
102 RecentActivityStore.recordRepository(currentRepository, defaults: appState.accountDefaults)
103 }
104 }
105 }
106
107 @ViewBuilder
108 private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View {
109 VStack(spacing: 0) {
110 Picker("Tab", selection: $selectedTab) {
111 ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
112 Text(tab.rawValue).tag(tab)
113 }
114 }
115 .pickerStyle(.segmented)
116 .padding(.horizontal)
117 .padding(.vertical, 8)
118
119 Divider()
120
121 switch selectedTab {
122 case .summary:
123 ReadmeView(viewModel: viewModel)
124 case .tree:
125 FileTreeView(
126 repository: currentRepository,
127 client: appState.client
128 )
129 case .log:
130 CommitLogView(viewModel: viewModel)
131 case .refs:
132 ReferencesListView(viewModel: viewModel)
133 case .artifacts:
134 ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
135 }
136 }
137 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
138 .srhtErrorBanner(error: Binding(
139 get: { viewModel.error },
140 set: { viewModel.error = $0 }
141 ))
142 }
143
144 private func normalizedUsername(_ value: String) -> String {
145 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
146 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
147 }
148
149 private var repositoryActionsMenu: some View {
150 Menu {
151 if currentUserKey != nil {
152 Button {
153 togglePinnedState()
154 } label: {
155 Label(
156 isPinnedToHome ? "Unpin from Home" : "Pin to Home",
157 systemImage: isPinnedToHome ? "pin.slash" : "pin"
158 )
159 }
160 }
161
162 if let shareURL = SRHTWebURL.repository(currentRepository) {
163 ShareLink(item: shareURL) {
164 Label("Share", systemImage: "square.and.arrow.up")
165 }
166 }
167
168 Divider()
169
170 if let repositoryURL = SRHTWebURL.repository(currentRepository) {
171 Button {
172 openURL(repositoryURL)
173 } label: {
174 Label("Open in Browser", systemImage: "safari")
175 }
176
177 Button {
178 appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL")
179 } label: {
180 Label("Copy URL", systemImage: "doc.on.doc")
181 }
182 }
183
184 if let httpsURL = SRHTWebURL.httpsCloneURL(currentRepository) {
185 Button {
186 appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL")
187 } label: {
188 Label("Copy HTTPS URL", systemImage: "doc.on.doc")
189 }
190 }
191
192 Button {
193 appState.copyToPasteboard(SRHTWebURL.sshCloneURL(currentRepository), label: "SSH clone URL")
194 } label: {
195 Label("Copy SSH URL", systemImage: "terminal")
196 }
197
198 Button {
199 appState.copyToPasteboard(currentRepository.rid, label: "repository RID")
200 } label: {
201 Label("Copy RID", systemImage: "number")
202 }
203
204 if canManageRepository {
205 Divider()
206
207 Button {
208 showACLs = true
209 } label: {
210 Label("Manage ACLs", systemImage: "person.2")
211 }
212
213 Button {
214 showDeployKeys = true
215 } label: {
216 Label("Deploy Keys", systemImage: "key")
217 }
218
219 Button {
220 showSettings = true
221 } label: {
222 Label("Repository Settings", systemImage: "gear")
223 }
224 }
225 } label: {
226 Image(systemName: "ellipsis.circle")
227 }
228 .accessibilityLabel("Repository actions")
229 }
230
231 private func togglePinnedState() {
232 guard let currentUserKey else { return }
233 HomePinStore.togglePin(.repository(currentRepository), for: currentUserKey, defaults: appState.accountDefaults)
234 pinChangeCount += 1
235 }
236}