gitbay/Views/Snippets/SnippetView.swift
258 lines · 9953 bytes
1import SwiftUI
2
3/// One snippet: its files, each rendered like a repository file, and the
4/// owner's controls to add, replace and remove files, change the
5/// description or visibility, and delete it.
6struct SnippetView: View {
7
8 @State private var model: SnippetDetailViewModel
9 @State private var addingFile = false
10 @State private var editingFile: SnippetFile?
11 @State private var removingFile: SnippetFile?
12 @State private var editingSettings = false
13 @State private var confirmingDelete = false
14 @State private var draftDescription = ""
15 @State private var draftVisibility = "unlisted"
16
17 @Environment(SessionStore.self) private var session
18 @Environment(\.dismiss) private var dismiss
19 @Environment(\.colorScheme) private var colorScheme
20
21 init(client: GitbayClient, id: String) {
22 _model = State(initialValue: SnippetDetailViewModel(client: client, id: id))
23 }
24
25 private var isOwner: Bool {
26 guard let snippet = model.state.value, let me = session.current?.username else { return false }
27 return snippet.owner == me
28 }
29
30 var body: some View {
31 List {
32 if let snippet = model.state.value {
33 header(snippet)
34 if let error = model.actionError {
35 Section { GBNotice(error, .gbWarn) }
36 }
37 ForEach(snippet.files) { file in
38 fileSection(file)
39 }
40 }
41 }
42 .overlay { LoadStateOverlay(state: model.state) }
43 .navigationTitle(model.state.value?.title ?? "Snippet")
44 .navigationBarTitleDisplayMode(.inline)
45 .toolbar {
46 if isOwner {
47 ToolbarItem(placement: .topBarTrailing) {
48 Menu {
49 Button {
50 addingFile = true
51 } label: {
52 Label("Add file", systemImage: "doc.badge.plus")
53 }
54 Button {
55 if let snippet = model.state.value {
56 draftDescription = snippet.description ?? ""
57 draftVisibility = snippet.visibility
58 }
59 editingSettings = true
60 } label: {
61 Label("Description & visibility", systemImage: "pencil")
62 }
63 Divider()
64 Button(role: .destructive) {
65 confirmingDelete = true
66 } label: {
67 Label("Delete snippet", systemImage: "trash")
68 }
69 } label: {
70 if model.working {
71 ProgressView()
72 } else {
73 Image(systemName: "ellipsis.circle")
74 }
75 }
76 .disabled(model.working)
77 .accessibilityIdentifier("snippet-actions-menu")
78 }
79 }
80 }
81 .task { await model.load() }
82 .refreshable { await model.load() }
83 .sheet(isPresented: $addingFile) {
84 SnippetFileSheet(
85 heading: "Add File", submitLabel: "Add", fileName: "", showsSettings: false,
86 working: model.working, errorMessage: model.actionError
87 ) { name, _, _, content in
88 Task {
89 await model.setFile(name, content: content)
90 if model.actionError == nil { addingFile = false }
91 }
92 }
93 }
94 .sheet(item: $editingFile) { file in
95 SnippetFileSheet(
96 heading: "Edit \(file.name)", submitLabel: "Save", fileName: file.name,
97 showsSettings: false, initialContent: file.content ?? "",
98 working: model.working, errorMessage: model.actionError
99 ) { name, _, _, content in
100 Task {
101 await model.setFile(name, content: content)
102 if model.actionError == nil { editingFile = nil }
103 }
104 }
105 }
106 .sheet(isPresented: $editingSettings) {
107 SnippetSettingsSheet(
108 description: $draftDescription, visibility: $draftVisibility,
109 working: model.working, errorMessage: model.actionError
110 ) {
111 Task {
112 await model.edit(description: draftDescription, visibility: draftVisibility)
113 if model.actionError == nil { editingSettings = false }
114 }
115 }
116 }
117 .confirmationDialog(
118 "Remove \(removingFile?.name ?? "")?",
119 isPresented: .init(
120 get: { removingFile != nil },
121 set: { if !$0 { removingFile = nil } }
122 ),
123 titleVisibility: .visible
124 ) {
125 Button("Remove", role: .destructive) {
126 guard let file = removingFile else { return }
127 removingFile = nil
128 Task { await model.removeFile(file.name) }
129 }
130 Button("Cancel", role: .cancel) { removingFile = nil }
131 }
132 .confirmationDialog(
133 "Delete this snippet and its files?",
134 isPresented: $confirmingDelete,
135 titleVisibility: .visible
136 ) {
137 Button("Delete", role: .destructive) {
138 Task {
139 if await model.delete() { dismiss() }
140 }
141 }
142 Button("Cancel", role: .cancel) {}
143 }
144 }
145
146 private func header(_ snippet: Snippet) -> some View {
147 Section {
148 VStack(alignment: .leading, spacing: 6) {
149 if let description = snippet.description, !description.isEmpty {
150 Text(description)
151 .font(.gbSans(.headline))
152 }
153 HStack(spacing: 6) {
154 GBChip(snippet.visibility, .secondary)
155 NavigationLink(value: RepoRoute.profile(snippet.owner)) {
156 Text(snippet.owner)
157 }
158 Text("updated")
159 Text(snippet.updatedAt, format: .relative(presentation: .named))
160 }
161 .font(.gbSans(.caption))
162 .foregroundStyle(.secondary)
163 Text(snippet.id)
164 .font(.gbMono(.caption2))
165 .foregroundStyle(.tertiary)
166 }
167 .padding(.vertical, 2)
168 }
169 }
170
171 private func fileSection(_ file: SnippetFile) -> some View {
172 Section {
173 ScrollView(.horizontal) {
174 code(file)
175 .padding(.vertical, 4)
176 }
177 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
178 } header: {
179 HStack {
180 Text(file.name)
181 .font(.gbMono(.caption))
182 .textCase(nil)
183 Spacer()
184 Text(Int64(file.size).formatted(.byteCount(style: .file)))
185 // A swipe on the code row scrolls the code, so the
186 // file's actions live on its header.
187 if isOwner {
188 Menu {
189 Button("Edit") { editingFile = file }
190 Button("Remove", role: .destructive) { removingFile = file }
191 } label: {
192 Image(systemName: "ellipsis.circle")
193 }
194 .textCase(nil)
195 .accessibilityIdentifier("snippet-file-menu-\(file.name)")
196 }
197 }
198 }
199 }
200
201 private func code(_ file: SnippetFile) -> some View {
202 let text = file.content ?? ""
203 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
204 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
205 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
206 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
207 if let highlighted = highlighter.highlight(text, fileName: file.name, font: font) {
208 return Text(AttributedString(highlighted))
209 }
210 return Text(text).font(.gbMono(.caption))
211 }
212}
213
214/// `snippet edit`: description and visibility.
215private struct SnippetSettingsSheet: View {
216
217 @Binding var description: String
218 @Binding var visibility: String
219 let working: Bool
220 let errorMessage: String?
221 let onSubmit: () -> Void
222
223 @Environment(\.dismiss) private var dismiss
224
225 var body: some View {
226 NavigationStack {
227 Form {
228 TextField("Description", text: $description)
229 .autocorrectionDisabled()
230 Picker("Visibility", selection: $visibility) {
231 Text("unlisted").tag("unlisted")
232 Text("public").tag("public")
233 Text("private").tag("private")
234 }
235 .pickerStyle(.segmented)
236 if let errorMessage {
237 Section { GBNotice(errorMessage) }
238 }
239 }
240 .navigationTitle("Snippet Settings")
241 .navigationBarTitleDisplayMode(.inline)
242 .toolbar {
243 ToolbarItem(placement: .cancellationAction) {
244 Button("Cancel") { dismiss() }
245 .disabled(working)
246 }
247 ToolbarItem(placement: .confirmationAction) {
248 if working {
249 ProgressView()
250 } else {
251 Button("Save", action: onSubmit)
252 }
253 }
254 }
255 .interactiveDismissDisabled(working)
256 }
257 }
258}