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