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}