gitbay/Views/Snippets/SnippetView.swift
266 lines · 10356 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 .accessibilityLabel("Actions")
77 .disabled(model.working)
78 .accessibilityIdentifier("snippet-actions-menu")
79 }
80 }
81 }
82 .task { await model.load() }
83 .refreshable { await model.load() }
84 .sheet(isPresented: $addingFile) {
85 SnippetFileSheet(
86 heading: "Add File", submitLabel: "Add", fileName: "", showsSettings: false,
87 working: model.working, errorMessage: model.actionError
88 ) { name, _, _, content in
89 Task {
90 await model.setFile(name, content: content)
91 if model.actionError == nil { addingFile = false }
92 }
93 }
94 }
95 .sheet(item: $editingFile) { file in
96 SnippetFileSheet(
97 heading: "Edit \(file.name)", submitLabel: "Save", fileName: file.name,
98 showsSettings: false, initialContent: file.content ?? "",
99 working: model.working, errorMessage: model.actionError
100 ) { name, _, _, content in
101 Task {
102 await model.setFile(name, content: content)
103 if model.actionError == nil { editingFile = nil }
104 }
105 }
106 }
107 .sheet(isPresented: $editingSettings) {
108 SnippetSettingsSheet(
109 description: $draftDescription, visibility: $draftVisibility,
110 working: model.working, errorMessage: model.actionError
111 ) {
112 Task {
113 await model.edit(description: draftDescription, visibility: draftVisibility)
114 if model.actionError == nil { editingSettings = false }
115 }
116 }
117 }
118 .confirmationDialog(
119 "Remove \(removingFile?.name ?? "")?",
120 isPresented: .init(
121 get: { removingFile != nil },
122 set: { if !$0 { removingFile = nil } }
123 ),
124 titleVisibility: .visible
125 ) {
126 Button("Remove", role: .destructive) {
127 guard let file = removingFile else { return }
128 removingFile = nil
129 Task { await model.removeFile(file.name) }
130 }
131 Button("Cancel", role: .cancel) { removingFile = nil }
132 }
133 .confirmationDialog(
134 "Delete this snippet and its files?",
135 isPresented: $confirmingDelete,
136 titleVisibility: .visible
137 ) {
138 Button("Delete", role: .destructive) {
139 Task {
140 if await model.delete() { dismiss() }
141 }
142 }
143 Button("Cancel", role: .cancel) {}
144 }
145 }
146
147 private func header(_ snippet: Snippet) -> some View {
148 Section {
149 VStack(alignment: .leading, spacing: 6) {
150 if let description = snippet.description, !description.isEmpty {
151 Text(description)
152 .font(.gbSans(.headline))
153 }
154 HStack(spacing: 6) {
155 GBChip(snippet.visibility, .secondary)
156 NavigationLink(value: RepoRoute.profile(snippet.owner)) {
157 Text(snippet.owner)
158 }
159 Text("updated")
160 Text(snippet.updatedAt, format: .relative(presentation: .named))
161 }
162 .font(.gbSans(.caption))
163 .foregroundStyle(.secondary)
164 Text(snippet.id)
165 .font(.gbMono(.caption2))
166 .foregroundStyle(.tertiary)
167 }
168 .padding(.vertical, 2)
169 }
170 }
171
172 private func fileSection(_ file: SnippetFile) -> some View {
173 Section {
174 ScrollView(.horizontal) {
175 code(file)
176 .padding(.vertical, 4)
177 }
178 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
179 } header: {
180 HStack {
181 Text(file.name)
182 .font(.gbMono(.caption))
183 .textCase(nil)
184 Spacer()
185 Text(Int64(file.size).formatted(.byteCount(style: .file)))
186 // A swipe on the code row scrolls the code, so the
187 // file's actions live on its header.
188 if isOwner {
189 Menu {
190 Button("Edit") { editingFile = file }
191 Button("Remove", role: .destructive) { removingFile = file }
192 } label: {
193 Image(systemName: "ellipsis.circle")
194 }
195 .accessibilityLabel("Actions for \(file.name)")
196 .textCase(nil)
197 .accessibilityIdentifier("snippet-file-menu-\(file.name)")
198 }
199 }
200 }
201 }
202
203 /// One Text per line: a single Text of a long file does not draw.
204 private func code(_ file: SnippetFile) -> some View {
205 let text = file.content ?? ""
206 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
207 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
208 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
209 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
210 let lines = codeLines(highlighter.highlight(text, fileName: file.name, font: font)
211 ?? NSAttributedString(string: text))
212 return VStack(alignment: .leading, spacing: 0) {
213 ForEach(lines.indices, id: \.self) { index in
214 Text(lines[index].characters.isEmpty ? " " : lines[index])
215 .fixedSize()
216 }
217 }
218 .font(.gbMono(.caption))
219 }
220}
221
222/// `snippet edit`: description and visibility.
223private struct SnippetSettingsSheet: View {
224
225 @Binding var description: String
226 @Binding var visibility: String
227 let working: Bool
228 let errorMessage: String?
229 let onSubmit: () -> Void
230
231 @Environment(\.dismiss) private var dismiss
232
233 var body: some View {
234 NavigationStack {
235 Form {
236 TextField("Description", text: $description)
237 .autocorrectionDisabled()
238 Picker("Visibility", selection: $visibility) {
239 Text("unlisted").tag("unlisted")
240 Text("public").tag("public")
241 Text("private").tag("private")
242 }
243 .pickerStyle(.segmented)
244 if let errorMessage {
245 Section { GBNotice(errorMessage) }
246 }
247 }
248 .navigationTitle("Snippet Settings")
249 .navigationBarTitleDisplayMode(.inline)
250 .toolbar {
251 ToolbarItem(placement: .cancellationAction) {
252 Button("Cancel") { dismiss() }
253 .disabled(working)
254 }
255 ToolbarItem(placement: .confirmationAction) {
256 if working {
257 ProgressView()
258 } else {
259 Button("Save", action: onSubmit)
260 }
261 }
262 }
263 .interactiveDismissDisabled(working)
264 }
265 }
266}