gitbay/Views/Repos/RepoView.swift
166 lines · 6910 bytes
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 private let path: String
7
8 init(client: GitbayClient, path: String) {
9 self.path = path
10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
11 }
12
13 @State private var confirmingArchive = false
14
15 var body: some View {
16 List {
17 if let detail = model.state.value {
18 if let error = model.actionError {
19 Section {
20 GBNotice(error, .gbWarn)
21 }
22 }
23 header(detail)
24
25 Section {
26 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
27 Label("Files", systemImage: "folder")
28 }
29 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
30 Label("History", systemImage: "clock")
31 }
32 NavigationLink(value: RepoRoute.refs(repo: path)) {
33 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
34 }
35 NavigationLink(value: MRRoute.list(repo: path)) {
36 Label("Merge Requests", systemImage: "arrow.triangle.merge")
37 }
38 NavigationLink(value: IssueRoute.list(repo: path)) {
39 Label("Issues", systemImage: "smallcircle.filled.circle")
40 }
41 NavigationLink(value: RepoRoute.milestones(repo: path)) {
42 Label("Milestones", systemImage: "flag")
43 }
44 NavigationLink(value: BuildRoute.list(repo: path)) {
45 Label("Builds", systemImage: "hammer")
46 }
47 NavigationLink(value: ReleaseRoute.list(repo: path)) {
48 Label("Releases", systemImage: "shippingbox")
49 }
50 NavigationLink(value: RepoRoute.wiki(repo: path)) {
51 Label("Wiki", systemImage: "book")
52 }
53 NavigationLink(value: RepoRoute.grep(repo: path)) {
54 Label("Search in Files", systemImage: "text.magnifyingglass")
55 }
56 NavigationLink(value: RepoRoute.settings(repo: path)) {
57 Label("Settings", systemImage: "gearshape")
58 }
59 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
60 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
61 }
62 }
63
64 if let readme = model.readme {
65 Section("README") {
66 ReadmeView(name: model.readmeName ?? "README.md", content: readme)
67 .padding(.vertical, 4)
68 }
69 }
70 }
71 }
72 .overlay { LoadStateOverlay(state: model.state) }
73 .navigationTitle(String(path.split(separator: "/").last ?? ""))
74 .navigationBarTitleDisplayMode(.inline)
75 .toolbar { toolbar }
76 .task { await model.load() }
77 .refreshable { await model.load() }
78 .confirmationDialog(
79 model.state.value?.isArchived == true
80 ? "Unarchive \(path)?"
81 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
82 isPresented: $confirmingArchive
83 ) {
84 Button(
85 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
86 role: model.state.value?.isArchived == true ? nil : .destructive
87 ) {
88 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
89 }
90 Button("Cancel", role: .cancel) {}
91 }
92 }
93
94 @ToolbarContentBuilder
95 private var toolbar: some ToolbarContent {
96 ToolbarItem(placement: .topBarTrailing) {
97 if let detail = model.state.value {
98 Menu {
99 if let pinned = model.isPinned {
100 Button {
101 Task { await model.setPinned(!pinned) }
102 } label: {
103 Label(pinned ? "Unpin" : "Pin",
104 systemImage: pinned ? "pin.slash" : "pin")
105 }
106 }
107 Divider()
108 Button(role: detail.isArchived ? nil : .destructive) {
109 confirmingArchive = true
110 } label: {
111 Label(detail.isArchived ? "Unarchive" : "Archive",
112 systemImage: "archivebox")
113 }
114 } label: {
115 if model.working {
116 ProgressView()
117 } else {
118 Image(systemName: "ellipsis.circle")
119 }
120 }
121 .disabled(model.working)
122 .accessibilityIdentifier("repo-actions-menu")
123 }
124 }
125 }
126
127 @ViewBuilder
128 private func header(_ detail: RepoDetail) -> some View {
129 Section {
130 VStack(alignment: .leading, spacing: 8) {
131 HStack(spacing: 6) {
132 Text(detail.path)
133 .font(.gbSans(.headline))
134 if detail.visibility == "private" {
135 Image(systemName: "lock.fill")
136 .font(.gbSans(.caption))
137 .foregroundStyle(.secondary)
138 }
139 if detail.isArchived {
140 GBChip("archived", .secondary)
141 }
142 }
143 if let description = detail.description, !description.isEmpty {
144 Text(description)
145 .font(.gbSans(.subheadline))
146 .foregroundStyle(.secondary)
147 }
148 if let website = detail.website, let url = URL(string: website) {
149 Link(website, destination: url)
150 .font(.gbSans(.caption))
151 .lineLimit(1)
152 }
153 HStack(spacing: 12) {
154 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
155 if let topics = detail.topics, !topics.isEmpty {
156 Text(topics.joined(separator: " · "))
157 .lineLimit(1)
158 }
159 }
160 .font(.gbSans(.caption))
161 .foregroundStyle(.secondary)
162 }
163 .padding(.vertical, 4)
164 }
165 }
166}