gitbay/Views/Repos/RepoView.swift

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

281 lines · 12054 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                    .accessibilityIdentifier("repo-settings-link")
 82                    NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
 83                        Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
 84                    }
 85                }
 86
 87                if let readme = model.readme {
 88                    Section("README") {
 89                        ReadmeView(
 90                            name: model.readmeName ?? "README.md",
 91                            content: readme,
 92                            host: host,
 93                            repoPath: path
 94                        )
 95                        .padding(.vertical, 4)
 96                    }
 97                }
 98            }
 99        }
100        .overlay { LoadStateOverlay(state: model.state) }
101        .navigationTitle(String(path.split(separator: "/").last ?? ""))
102        .navigationBarTitleDisplayMode(.inline)
103        .toolbar { toolbar }
104        .task { await model.load() }
105        .refreshable { await model.load() }
106        .navigationDestination(item: $forkDestination) { route in
107            if case .repo(let forkedPath) = route {
108                RepoView(client: client, path: forkedPath)
109            }
110        }
111        .alert("Fork \(path)", isPresented: $confirmingFork) {
112            TextField("New name (optional)", text: $forkName)
113                .autocorrectionDisabled()
114                .textInputAutocapitalization(.never)
115            Button("Fork") {
116                Task {
117                    if let result = await actionsModel.fork(named: forkName) {
118                        forkDestination = .repo(result.path)
119                    }
120                }
121            }
122            Button("Cancel", role: .cancel) {}
123        }
124        .confirmationDialog(
125            model.state.value?.isArchived == true
126                ? "Unarchive \(path)?"
127                : "Archive \(path)? Pushes and issue/MR writes will be refused.",
128            isPresented: $confirmingArchive
129        ) {
130            Button(
131                model.state.value?.isArchived == true ? "Unarchive" : "Archive",
132                role: model.state.value?.isArchived == true ? nil : .destructive
133            ) {
134                Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
135            }
136            Button("Cancel", role: .cancel) {}
137        }
138    }
139
140    @ToolbarContentBuilder
141    private var toolbar: some ToolbarContent {
142        ToolbarItem(placement: .topBarTrailing) {
143            if let detail = model.state.value {
144                Menu {
145                    if let pinned = model.isPinned {
146                        Button {
147                            Task { await model.setPinned(!pinned) }
148                        } label: {
149                            Label(pinned ? "Unpin" : "Pin",
150                                  systemImage: pinned ? "pin.slash" : "pin")
151                        }
152                    }
153                    Button {
154                        Task {
155                            await actionsModel.setBookmarked(!detail.bookmarked)
156                            await model.reloadDetail()
157                        }
158                    } label: {
159                        Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
160                              systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
161                    }
162                    Divider()
163                    Section {
164                        let watchState = WatchState(detail.watch)
165                        switch watchState {
166                        case .watching:
167                            Label("Watching", systemImage: "eye")
168                        case .muted:
169                            Label("Muted", systemImage: "bell.slash")
170                        case .default:
171                            EmptyView()
172                        }
173                        if watchState.available.contains(.watch) {
174                            Button {
175                                Task {
176                                    await actionsModel.watch()
177                                    await model.reloadDetail()
178                                }
179                            } label: {
180                                Label("Watch this repository", systemImage: "eye")
181                            }
182                        }
183                        if watchState.available.contains(.unwatch) {
184                            Button {
185                                Task {
186                                    await actionsModel.unwatch()
187                                    await model.reloadDetail()
188                                }
189                            } label: {
190                                if watchState == .muted {
191                                    Label("Unmute this repository", systemImage: "bell")
192                                } else {
193                                    Label("Stop watching", systemImage: "eye.slash")
194                                }
195                            }
196                        }
197                        if watchState.available.contains(.mute) {
198                            Button {
199                                Task {
200                                    await actionsModel.mute()
201                                    await model.reloadDetail()
202                                }
203                            } label: {
204                                Label("Mute this repository", systemImage: "bell.slash")
205                            }
206                        }
207                    }
208                    Divider()
209                    Button {
210                        forkName = ""
211                        confirmingFork = true
212                    } label: {
213                        Label("Fork", systemImage: "tuningfork")
214                    }
215                    Divider()
216                    Button(role: detail.isArchived ? nil : .destructive) {
217                        confirmingArchive = true
218                    } label: {
219                        Label(detail.isArchived ? "Unarchive" : "Archive",
220                              systemImage: "archivebox")
221                    }
222                } label: {
223                    if model.working || actionsModel.working {
224                        ProgressView()
225                    } else {
226                        Image(systemName: "ellipsis.circle")
227                    }
228                }
229                .disabled(model.working || actionsModel.working)
230                .accessibilityIdentifier("repo-actions-menu")
231            }
232        }
233    }
234
235    @ViewBuilder
236    private func header(_ detail: RepoDetail) -> some View {
237        Section {
238            VStack(alignment: .leading, spacing: 8) {
239                HStack(spacing: 6) {
240                    Text(detail.path)
241                        .font(.gbSans(.headline))
242                    if detail.visibility == "private" {
243                        Image(systemName: "lock.fill")
244                            .font(.gbSans(.caption))
245                            .foregroundStyle(.secondary)
246                    }
247                    if detail.isArchived {
248                        GBChip("archived", .secondary)
249                    }
250                }
251                if let description = detail.description, !description.isEmpty {
252                    Text(description)
253                        .font(.gbSans(.subheadline))
254                        .foregroundStyle(.secondary)
255                }
256                if let website = detail.website, let url = URL(string: website) {
257                    Link(website, destination: url)
258                        .font(.gbSans(.caption))
259                        .lineLimit(1)
260                }
261                HStack(spacing: 12) {
262                    Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
263                    if let topics = detail.topics, !topics.isEmpty {
264                        Text(topics.joined(separator: " · "))
265                            .lineLimit(1)
266                    }
267                }
268                .font(.gbSans(.caption))
269                .foregroundStyle(.secondary)
270                if let forkOf = detail.forkOf {
271                    NavigationLink(value: RepoRoute.repo(forkOf)) {
272                        Text("Forked from \(forkOf)")
273                    }
274                    .font(.gbSans(.caption))
275                    .foregroundStyle(.secondary)
276                }
277            }
278            .padding(.vertical, 4)
279        }
280    }
281}