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