import SwiftUI /// One snippet: its files, each rendered like a repository file, and the /// owner's controls to add, replace and remove files, change the /// description or visibility, and delete it. struct SnippetView: View { @State private var model: SnippetDetailViewModel @State private var addingFile = false @State private var editingFile: SnippetFile? @State private var removingFile: SnippetFile? @State private var editingSettings = false @State private var confirmingDelete = false @State private var draftDescription = "" @State private var draftVisibility = "unlisted" @Environment(SessionStore.self) private var session @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme init(client: GitbayClient, id: String) { _model = State(initialValue: SnippetDetailViewModel(client: client, id: id)) } private var isOwner: Bool { guard let snippet = model.state.value, let me = session.current?.username else { return false } return snippet.owner == me } var body: some View { List { if let snippet = model.state.value { header(snippet) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(snippet.files) { file in fileSection(file) } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(model.state.value?.title ?? "Snippet") .navigationBarTitleDisplayMode(.inline) .toolbar { if isOwner { ToolbarItem(placement: .topBarTrailing) { Menu { Button { addingFile = true } label: { Label("Add file", systemImage: "doc.badge.plus") } Button { if let snippet = model.state.value { draftDescription = snippet.description ?? "" draftVisibility = snippet.visibility } editingSettings = true } label: { Label("Description & visibility", systemImage: "pencil") } Divider() Button(role: .destructive) { confirmingDelete = true } label: { Label("Delete snippet", systemImage: "trash") } } label: { if model.working { ProgressView() } else { Image(systemName: "ellipsis.circle") } } .disabled(model.working) .accessibilityIdentifier("snippet-actions-menu") } } } .task { await model.load() } .refreshable { await model.load() } .sheet(isPresented: $addingFile) { SnippetFileSheet( heading: "Add File", submitLabel: "Add", fileName: "", showsSettings: false, working: model.working, errorMessage: model.actionError ) { name, _, _, content in Task { await model.setFile(name, content: content) if model.actionError == nil { addingFile = false } } } } .sheet(item: $editingFile) { file in SnippetFileSheet( heading: "Edit \(file.name)", submitLabel: "Save", fileName: file.name, showsSettings: false, initialContent: file.content ?? "", working: model.working, errorMessage: model.actionError ) { name, _, _, content in Task { await model.setFile(name, content: content) if model.actionError == nil { editingFile = nil } } } } .sheet(isPresented: $editingSettings) { SnippetSettingsSheet( description: $draftDescription, visibility: $draftVisibility, working: model.working, errorMessage: model.actionError ) { Task { await model.edit(description: draftDescription, visibility: draftVisibility) if model.actionError == nil { editingSettings = false } } } } .confirmationDialog( "Remove \(removingFile?.name ?? "")?", isPresented: .init( get: { removingFile != nil }, set: { if !$0 { removingFile = nil } } ), titleVisibility: .visible ) { Button("Remove", role: .destructive) { guard let file = removingFile else { return } removingFile = nil Task { await model.removeFile(file.name) } } Button("Cancel", role: .cancel) { removingFile = nil } } .confirmationDialog( "Delete this snippet and its files?", isPresented: $confirmingDelete, titleVisibility: .visible ) { Button("Delete", role: .destructive) { Task { if await model.delete() { dismiss() } } } Button("Cancel", role: .cancel) {} } } private func header(_ snippet: Snippet) -> some View { Section { VStack(alignment: .leading, spacing: 6) { if let description = snippet.description, !description.isEmpty { Text(description) .font(.gbSans(.headline)) } HStack(spacing: 6) { GBChip(snippet.visibility, .secondary) NavigationLink(value: RepoRoute.profile(snippet.owner)) { Text(snippet.owner) } Text("updated") Text(snippet.updatedAt, format: .relative(presentation: .named)) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) Text(snippet.id) .font(.gbMono(.caption2)) .foregroundStyle(.tertiary) } .padding(.vertical, 2) } } private func fileSection(_ file: SnippetFile) -> some View { Section { ScrollView(.horizontal) { code(file) .padding(.vertical, 4) } .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) } header: { HStack { Text(file.name) .font(.gbMono(.caption)) .textCase(nil) Spacer() Text(Int64(file.size).formatted(.byteCount(style: .file))) // A swipe on the code row scrolls the code, so the // file's actions live on its header. if isOwner { Menu { Button("Edit") { editingFile = file } Button("Remove", role: .destructive) { removingFile = file } } label: { Image(systemName: "ellipsis.circle") } .textCase(nil) .accessibilityIdentifier("snippet-file-menu-\(file.name)") } } } } private func code(_ file: SnippetFile) -> some View { let text = file.content ?? "" let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) ?? .monospacedSystemFont(ofSize: size, weight: .regular) let highlighter = SyntaxHighlighter(colorScheme: colorScheme) if let highlighted = highlighter.highlight(text, fileName: file.name, font: font) { return Text(AttributedString(highlighted)) } return Text(text).font(.gbMono(.caption)) } } /// `snippet edit`: description and visibility. private struct SnippetSettingsSheet: View { @Binding var description: String @Binding var visibility: String let working: Bool let errorMessage: String? let onSubmit: () -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { TextField("Description", text: $description) .autocorrectionDisabled() Picker("Visibility", selection: $visibility) { Text("unlisted").tag("unlisted") Text("public").tag("public") Text("private").tag("private") } .pickerStyle(.segmented) if let errorMessage { Section { GBNotice(errorMessage) } } } .navigationTitle("Snippet Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .disabled(working) } ToolbarItem(placement: .confirmationAction) { if working { ProgressView() } else { Button("Save", action: onSubmit) } } } .interactiveDismissDisabled(working) } } }