gitbay/Views/Snippets/SnippetView.swift

v1.6.1
gitbay-ios/gitbay/Views/Snippets/SnippetView.swift history · blame · raw

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}