gitbay/Views/Snippets/SnippetView.swift

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

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}