gitbay/Views/Repos/RepoView.swift

v1.0.6
gitbay-ios/gitbay/Views/Repos/RepoView.swift history · blame · raw

280 lines · 11987 bytes

  1import SwiftUI
  2
  3struct RepoView: View {
  4
  5    @State private var model: RepoDetailViewModel
  6    @State private var actionsModel: RepoActionsViewModel
  7    private let client: GitbayClient
  8    private let path: String
  9    private let host: String
 10
 11    init(client: GitbayClient, path: String) {
 12        self.client = client
 13        self.path = path
 14        self.host = client.instance.baseURL.host() ?? ""
 15        _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
 16        _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path))
 17    }
 18
 19    @State private var confirmingArchive = false
 20    @State private var confirmingFork = false
 21    @State private var forkName = ""
 22    @State private var forkDestination: RepoRoute?
 23
 24    var body: some View {
 25        List {
 26            if let detail = model.state.value {
 27                if let error = model.actionError {
 28                    Section {
 29                        GBNotice(error, .gbWarn)
 30                    }
 31                }
 32                if let error = actionsModel.actionError {
 33                    Section {
 34                        GBNotice(error, .gbWarn)
 35                    }
 36                }
 37                if let notice = actionsModel.notice {
 38                    Section {
 39                        GBNotice(notice, .gbOK)
 40                    }
 41                }
 42                header(detail)
 43
 44                Section {
 45                    NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
 46                        Label("Files", systemImage: "folder")
 47                    }
 48                    NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
 49                        Label("History", systemImage: "clock")
 50                    }
 51                    NavigationLink(value: RepoRoute.refs(repo: path)) {
 52                        Label("Branches & Tags", systemImage: "arrow.triangle.branch")
 53                    }
 54                    NavigationLink(value: MRRoute.list(repo: path)) {
 55                        Label("Merge Requests", systemImage: "arrow.triangle.merge")
 56                    }
 57                    NavigationLink(value: IssueRoute.list(repo: path)) {
 58                        Label("Issues", systemImage: "smallcircle.filled.circle")
 59                    }
 60                    NavigationLink(value: RepoRoute.milestones(repo: path)) {
 61                        Label("Milestones", systemImage: "flag")
 62                    }
 63                    NavigationLink(value: RepoRoute.labels(repo: path)) {
 64                        Label("Labels", systemImage: "tag")
 65                    }
 66                    NavigationLink(value: BuildRoute.list(repo: path)) {
 67                        Label("Builds", systemImage: "hammer")
 68                    }
 69                    NavigationLink(value: ReleaseRoute.list(repo: path)) {
 70                        Label("Releases", systemImage: "shippingbox")
 71                    }
 72                    NavigationLink(value: RepoRoute.wiki(repo: path)) {
 73                        Label("Wiki", systemImage: "book")
 74                    }
 75                    NavigationLink(value: RepoRoute.grep(repo: path)) {
 76                        Label("Search in Files", systemImage: "text.magnifyingglass")
 77                    }
 78                    NavigationLink(value: RepoRoute.settings(repo: path)) {
 79                        Label("Settings", systemImage: "gearshape")
 80                    }
 81                    NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
 82                        Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
 83                    }
 84                }
 85
 86                if let readme = model.readme {
 87                    Section("README") {
 88                        ReadmeView(
 89                            name: model.readmeName ?? "README.md",
 90                            content: readme,
 91                            host: host,
 92                            repoPath: path
 93                        )
 94                        .padding(.vertical, 4)
 95                    }
 96                }
 97            }
 98        }
 99        .overlay { LoadStateOverlay(state: model.state) }
100        .navigationTitle(String(path.split(separator: "/").last ?? ""))
101        .navigationBarTitleDisplayMode(.inline)
102        .toolbar { toolbar }
103        .task { await model.load() }
104        .refreshable { await model.load() }
105        .navigationDestination(item: $forkDestination) { route in
106            if case .repo(let forkedPath) = route {
107                RepoView(client: client, path: forkedPath)
108            }
109        }
110        .alert("Fork \(path)", isPresented: $confirmingFork) {
111            TextField("New name (optional)", text: $forkName)
112                .autocorrectionDisabled()
113                .textInputAutocapitalization(.never)
114            Button("Fork") {
115                Task {
116                    if let result = await actionsModel.fork(named: forkName) {
117                        forkDestination = .repo(result.path)
118                    }
119                }
120            }
121            Button("Cancel", role: .cancel) {}
122        }
123        .confirmationDialog(
124            model.state.value?.isArchived == true
125                ? "Unarchive \(path)?"
126                : "Archive \(path)? Pushes and issue/MR writes will be refused.",
127            isPresented: $confirmingArchive
128        ) {
129            Button(
130                model.state.value?.isArchived == true ? "Unarchive" : "Archive",
131                role: model.state.value?.isArchived == true ? nil : .destructive
132            ) {
133                Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
134            }
135            Button("Cancel", role: .cancel) {}
136        }
137    }
138
139    @ToolbarContentBuilder
140    private var toolbar: some ToolbarContent {
141        ToolbarItem(placement: .topBarTrailing) {
142            if let detail = model.state.value {
143                Menu {
144                    if let pinned = model.isPinned {
145                        Button {
146                            Task { await model.setPinned(!pinned) }
147                        } label: {
148                            Label(pinned ? "Unpin" : "Pin",
149                                  systemImage: pinned ? "pin.slash" : "pin")
150                        }
151                    }
152                    Button {
153                        Task {
154                            await actionsModel.setBookmarked(!detail.bookmarked)
155                            await model.reloadDetail()
156                        }
157                    } label: {
158                        Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
159                              systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
160                    }
161                    Divider()
162                    Section {
163                        let watchState = WatchState(detail.watch)
164                        switch watchState {
165                        case .watching:
166                            Label("Watching", systemImage: "eye")
167                        case .muted:
168                            Label("Muted", systemImage: "bell.slash")
169                        case .default:
170                            EmptyView()
171                        }
172                        if watchState.available.contains(.watch) {
173                            Button {
174                                Task {
175                                    await actionsModel.watch()
176                                    await model.reloadDetail()
177                                }
178                            } label: {
179                                Label("Watch this repository", systemImage: "eye")
180                            }
181                        }
182                        if watchState.available.contains(.unwatch) {
183                            Button {
184                                Task {
185                                    await actionsModel.unwatch()
186                                    await model.reloadDetail()
187                                }
188                            } label: {
189                                if watchState == .muted {
190                                    Label("Unmute this repository", systemImage: "bell")
191                                } else {
192                                    Label("Stop watching", systemImage: "eye.slash")
193                                }
194                            }
195                        }
196                        if watchState.available.contains(.mute) {
197                            Button {
198                                Task {
199                                    await actionsModel.mute()
200                                    await model.reloadDetail()
201                                }
202                            } label: {
203                                Label("Mute this repository", systemImage: "bell.slash")
204                            }
205                        }
206                    }
207                    Divider()
208                    Button {
209                        forkName = ""
210                        confirmingFork = true
211                    } label: {
212                        Label("Fork", systemImage: "tuningfork")
213                    }
214                    Divider()
215                    Button(role: detail.isArchived ? nil : .destructive) {
216                        confirmingArchive = true
217                    } label: {
218                        Label(detail.isArchived ? "Unarchive" : "Archive",
219                              systemImage: "archivebox")
220                    }
221                } label: {
222                    if model.working || actionsModel.working {
223                        ProgressView()
224                    } else {
225                        Image(systemName: "ellipsis.circle")
226                    }
227                }
228                .disabled(model.working || actionsModel.working)
229                .accessibilityIdentifier("repo-actions-menu")
230            }
231        }
232    }
233
234    @ViewBuilder
235    private func header(_ detail: RepoDetail) -> some View {
236        Section {
237            VStack(alignment: .leading, spacing: 8) {
238                HStack(spacing: 6) {
239                    Text(detail.path)
240                        .font(.gbSans(.headline))
241                    if detail.visibility == "private" {
242                        Image(systemName: "lock.fill")
243                            .font(.gbSans(.caption))
244                            .foregroundStyle(.secondary)
245                    }
246                    if detail.isArchived {
247                        GBChip("archived", .secondary)
248                    }
249                }
250                if let description = detail.description, !description.isEmpty {
251                    Text(description)
252                        .font(.gbSans(.subheadline))
253                        .foregroundStyle(.secondary)
254                }
255                if let website = detail.website, let url = URL(string: website) {
256                    Link(website, destination: url)
257                        .font(.gbSans(.caption))
258                        .lineLimit(1)
259                }
260                HStack(spacing: 12) {
261                    Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
262                    if let topics = detail.topics, !topics.isEmpty {
263                        Text(topics.joined(separator: " · "))
264                            .lineLimit(1)
265                    }
266                }
267                .font(.gbSans(.caption))
268                .foregroundStyle(.secondary)
269                if let forkOf = detail.forkOf {
270                    NavigationLink(value: RepoRoute.repo(forkOf)) {
271                        Text("Forked from \(forkOf)")
272                    }
273                    .font(.gbSans(.caption))
274                    .foregroundStyle(.secondary)
275                }
276            }
277            .padding(.vertical, 4)
278        }
279    }
280}