Commit b96f8005cf
Unsigned
Layout: unified · split
gitbay/ContentView.swift +13
| @@ -75,6 +75,9 @@ private struct RouteDestinations: ViewModifier { | ||
| 75 | 75 | .navigationDestination(for: OrgRoute.self) { route in |
| 76 | 76 | destination(route, client: client) |
| 77 | 77 | } |
| 78 | .navigationDestination(for: SnippetRoute.self) { route in | |
| 79 | destination(route, client: client) | |
| 80 | } | |
| 78 | 81 | } |
| 79 | 82 | |
| 80 | 83 | @ViewBuilder |
| @@ -163,6 +166,16 @@ private struct RouteDestinations: ViewModifier { | ||
| 163 | 166 | } |
| 164 | 167 | } |
| 165 | 168 | |
| 169 | @ViewBuilder | |
| 170 | private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View { | |
| 171 | switch route { | |
| 172 | case .list(let owner): | |
| 173 | SnippetListView(client: client, owner: owner) | |
| 174 | case .snippet(let id): | |
| 175 | SnippetView(client: client, id: id) | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 166 | 179 | @ViewBuilder |
| 167 | 180 | private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View { |
| 168 | 181 | switch route { |
gitbay/Snippets/SnippetDetailViewModel.swift added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `snippet show <id>` with every file's content, plus the writes an | |
| 5 | /// owner has: `snippet edit`, `snippet file set`, `snippet file remove` | |
| 6 | /// and `snippet delete`. | |
| 7 | @Observable | |
| 8 | @MainActor | |
| 9 | final class SnippetDetailViewModel { | |
| 10 | ||
| 11 | private(set) var state: LoadState<Snippet> = .loading | |
| 12 | private(set) var actionError: String? | |
| 13 | private(set) var working = false | |
| 14 | ||
| 15 | private let client: GitbayClient | |
| 16 | let id: String | |
| 17 | ||
| 18 | init(client: GitbayClient, id: String) { | |
| 19 | self.client = client | |
| 20 | self.id = id | |
| 21 | } | |
| 22 | ||
| 23 | func load() async { | |
| 24 | do { | |
| 25 | state = .loaded(try await client.read(["snippet", "show", id], as: Snippet.self)) | |
| 26 | } catch { | |
| 27 | state = .from(error) | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /// `snippet edit <id> [--description <d>] [--visibility <v>]`: a nil | |
| 32 | /// field is left as it is. An empty description clears it. | |
| 33 | func edit(description: String?, visibility: String?) async { | |
| 34 | var argv = ["snippet", "edit", id] | |
| 35 | if let description { argv += ["--description", description] } | |
| 36 | if let visibility { argv += ["--visibility", visibility] } | |
| 37 | await perform(argv) | |
| 38 | } | |
| 39 | ||
| 40 | /// `snippet file set <id> <filename> < file`: adds the file or | |
| 41 | /// replaces its content. | |
| 42 | func setFile(_ name: String, content: String) async { | |
| 43 | await perform(["snippet", "file", "set", id, name.trimmingCharacters(in: .whitespaces)], stdin: content) | |
| 44 | } | |
| 45 | ||
| 46 | func removeFile(_ name: String) async { | |
| 47 | await perform(["snippet", "file", "remove", id, name]) | |
| 48 | } | |
| 49 | ||
| 50 | /// `snippet delete <id>`. There is nothing to reload afterwards; the | |
| 51 | /// screen pops on true. | |
| 52 | func delete() async -> Bool { | |
| 53 | working = true | |
| 54 | actionError = nil | |
| 55 | defer { working = false } | |
| 56 | do { | |
| 57 | try await client.run(["snippet", "delete", id]) | |
| 58 | return true | |
| 59 | } catch let error as GitbayError { | |
| 60 | actionError = error.userFacingMessage | |
| 61 | return false | |
| 62 | } catch { | |
| 63 | actionError = GitbayError.transport(error).userFacingMessage | |
| 64 | return false | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | private func perform(_ argv: [String], stdin: String? = nil) async { | |
| 69 | working = true | |
| 70 | actionError = nil | |
| 71 | defer { working = false } | |
| 72 | do { | |
| 73 | try await client.run(argv, stdin: stdin) | |
| 74 | await load() | |
| 75 | } catch let error as GitbayError { | |
| 76 | if case .usage(let message) = error, !message.isEmpty { | |
| 77 | actionError = message | |
| 78 | } else { | |
| 79 | actionError = error.userFacingMessage | |
| 80 | } | |
| 81 | } catch { | |
| 82 | actionError = GitbayError.transport(error).userFacingMessage | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
gitbay/Snippets/SnippetListViewModel.swift added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `snippet list` — the caller's own snippets at every visibility, or an | |
| 5 | /// owner's public ones — paginated, plus `snippet create`. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class SnippetListViewModel { | |
| 9 | ||
| 10 | let list: PagedListModel<Snippet> | |
| 11 | /// nil lists the caller's own. | |
| 12 | let owner: String? | |
| 13 | private(set) var actionError: String? | |
| 14 | private(set) var working = false | |
| 15 | ||
| 16 | private let client: GitbayClient | |
| 17 | ||
| 18 | init(client: GitbayClient, owner: String?) { | |
| 19 | self.client = client | |
| 20 | self.owner = owner | |
| 21 | list = PagedListModel( | |
| 22 | client: client, | |
| 23 | argv: ["snippet", "list"] + (owner.map { [$0] } ?? []), | |
| 24 | emptyMessage: owner == nil | |
| 25 | ? "No snippets yet. Create one from the + button." | |
| 26 | : "\(owner ?? "") has no public snippets." | |
| 27 | ) | |
| 28 | } | |
| 29 | ||
| 30 | var state: LoadState<[Snippet]> { list.state } | |
| 31 | ||
| 32 | func load() async { | |
| 33 | await list.reload() | |
| 34 | } | |
| 35 | ||
| 36 | /// `snippet create <filename> [--description <d>] [--visibility <v>] | |
| 37 | /// < file` — the file is bare stdin. An empty description sends no | |
| 38 | /// flag. Returns the created snippet, or nil with `actionError` set. | |
| 39 | func create(filename: String, description: String, visibility: String, content: String) async -> Snippet? { | |
| 40 | var argv = ["snippet", "create", filename.trimmingCharacters(in: .whitespaces)] | |
| 41 | let description = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 42 | if !description.isEmpty { argv += ["--description", description] } | |
| 43 | argv += ["--visibility", visibility] | |
| 44 | working = true | |
| 45 | actionError = nil | |
| 46 | defer { working = false } | |
| 47 | do { | |
| 48 | let created = try await client.run(argv, stdin: content, as: Snippet.self) | |
| 49 | await list.reload() | |
| 50 | return created | |
| 51 | } catch let error as GitbayError { | |
| 52 | // Exit 2 here is the server validating the file name, the | |
| 53 | // size or the per-account cap — written for the person. | |
| 54 | if case .usage(let message) = error, !message.isEmpty { | |
| 55 | actionError = message | |
| 56 | } else { | |
| 57 | actionError = error.userFacingMessage | |
| 58 | } | |
| 59 | return nil | |
| 60 | } catch { | |
| 61 | actionError = GitbayError.transport(error).userFacingMessage | |
| 62 | return nil | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
gitbay/Snippets/SnippetModels.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One file of a snippet. `content` rides only on `snippet show`; a list | |
| 4 | /// row carries the name and size. | |
| 5 | nonisolated struct SnippetFile: Decodable, Sendable, Hashable, Identifiable { | |
| 6 | let name: String | |
| 7 | let size: Int | |
| 8 | let content: String? | |
| 9 | ||
| 10 | var id: String { name } | |
| 11 | } | |
| 12 | ||
| 13 | /// One row of `snippet list`, and the whole of `snippet show`. | |
| 14 | nonisolated struct Snippet: Decodable, Sendable, Hashable, Identifiable { | |
| 15 | let id: String | |
| 16 | let url: String? | |
| 17 | let owner: String | |
| 18 | /// Omitted by the server when empty. | |
| 19 | let description: String? | |
| 20 | /// public, unlisted or private. | |
| 21 | let visibility: String | |
| 22 | let createdAt: Date | |
| 23 | let updatedAt: Date | |
| 24 | let files: [SnippetFile] | |
| 25 | ||
| 26 | enum CodingKeys: String, CodingKey { | |
| 27 | case id, url, owner, description, visibility, files | |
| 28 | case createdAt = "created_at" | |
| 29 | case updatedAt = "updated_at" | |
| 30 | } | |
| 31 | ||
| 32 | /// The web titles a snippet by its description and falls back to | |
| 33 | /// the id. | |
| 34 | var title: String { | |
| 35 | if let description, !description.isEmpty { return description } | |
| 36 | return id | |
| 37 | } | |
| 38 | ||
| 39 | var fileNames: String { files.map(\.name).joined(separator: ", ") } | |
| 40 | } | |
gitbay/Views/Discovery/ProfileView.swift +6
| @@ -38,6 +38,12 @@ struct ProfileView: View { | ||
| 38 | 38 | Label("Members & Teams", systemImage: "person.3") |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | } else { | |
| 42 | Section { | |
| 43 | NavigationLink(value: SnippetRoute.list(owner: profile.name)) { | |
| 44 | Label("Snippets", systemImage: "scissors") | |
| 45 | } | |
| 46 | } | |
| 41 | 47 | } |
| 42 | 48 | } |
| 43 | 49 | } |
gitbay/Views/Repos/RepoListView.swift +3
| @@ -262,6 +262,9 @@ struct AccountMenu: ToolbarContent { | ||
| 262 | 262 | NavigationLink(value: RepoRoute.account) { |
| 263 | 263 | Label("Keys & Email", systemImage: "key") |
| 264 | 264 | } |
| 265 | NavigationLink(value: SnippetRoute.list(owner: nil)) { | |
| 266 | Label("Snippets", systemImage: "scissors") | |
| 267 | } | |
| 265 | 268 | } |
| 266 | 269 | } |
| 267 | 270 | if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { |
gitbay/Views/Snippets/SnippetListView.swift added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Your snippets at every visibility, or an owner's public ones. | |
| 4 | struct SnippetListView: View { | |
| 5 | ||
| 6 | @State private var model: SnippetListViewModel | |
| 7 | @State private var composing = false | |
| 8 | ||
| 9 | init(client: GitbayClient, owner: String?) { | |
| 10 | _model = State(initialValue: SnippetListViewModel(client: client, owner: owner)) | |
| 11 | } | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | List { | |
| 15 | ForEach(model.state.value ?? []) { snippet in | |
| 16 | NavigationLink(value: SnippetRoute.snippet(id: snippet.id)) { | |
| 17 | SnippetRow(snippet: snippet, showsVisibility: model.owner == nil) | |
| 18 | } | |
| 19 | } | |
| 20 | PageFooter(list: model.list) | |
| 21 | } | |
| 22 | .overlay { LoadStateOverlay(state: model.state) } | |
| 23 | .navigationTitle(model.owner.map { "\($0)'s snippets" } ?? "Snippets") | |
| 24 | .navigationBarTitleDisplayMode(.inline) | |
| 25 | .toolbar { | |
| 26 | if model.owner == nil { | |
| 27 | ToolbarItem(placement: .topBarTrailing) { | |
| 28 | Button { | |
| 29 | composing = true | |
| 30 | } label: { | |
| 31 | Image(systemName: "plus") | |
| 32 | } | |
| 33 | .accessibilityIdentifier("snippet-create-button") | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
| 37 | .sheet(isPresented: $composing) { | |
| 38 | SnippetFileSheet( | |
| 39 | heading: "New Snippet", | |
| 40 | submitLabel: "Create", | |
| 41 | fileName: "", | |
| 42 | showsSettings: true, | |
| 43 | working: model.working, | |
| 44 | errorMessage: model.actionError | |
| 45 | ) { name, description, visibility, content in | |
| 46 | Task { | |
| 47 | if await model.create( | |
| 48 | filename: name, description: description, | |
| 49 | visibility: visibility, content: content) != nil { | |
| 50 | composing = false | |
| 51 | } | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | .task { await model.load() } | |
| 56 | .refreshable { await model.load() } | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | struct SnippetRow: View { | |
| 61 | let snippet: Snippet | |
| 62 | let showsVisibility: Bool | |
| 63 | ||
| 64 | var body: some View { | |
| 65 | VStack(alignment: .leading, spacing: 3) { | |
| 66 | HStack(spacing: 6) { | |
| 67 | Text(snippet.title) | |
| 68 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 69 | .lineLimit(1) | |
| 70 | if showsVisibility, snippet.visibility != "public" { | |
| 71 | GBChip(snippet.visibility, .secondary) | |
| 72 | } | |
| 73 | } | |
| 74 | HStack(spacing: 6) { | |
| 75 | Text(snippet.fileNames) | |
| 76 | .font(.gbMono(.caption)) | |
| 77 | .lineLimit(1) | |
| 78 | Spacer() | |
| 79 | Text(snippet.updatedAt, format: .relative(presentation: .named)) | |
| 80 | } | |
| 81 | .font(.gbSans(.caption)) | |
| 82 | .foregroundStyle(.secondary) | |
| 83 | } | |
| 84 | .padding(.vertical, 2) | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /// A file's name and content, with the description and visibility when | |
| 89 | /// creating. Also used to add a file, or to replace one's content, in | |
| 90 | /// which case the name is fixed. | |
| 91 | struct SnippetFileSheet: View { | |
| 92 | ||
| 93 | let heading: String | |
| 94 | let submitLabel: String | |
| 95 | let fileName: String | |
| 96 | let showsSettings: Bool | |
| 97 | let working: Bool | |
| 98 | let errorMessage: String? | |
| 99 | /// name, description, visibility, content. | |
| 100 | let onSubmit: (String, String, String, String) -> Void | |
| 101 | ||
| 102 | @Environment(\.dismiss) private var dismiss | |
| 103 | @State private var name: String | |
| 104 | @State private var description = "" | |
| 105 | @State private var visibility = "unlisted" | |
| 106 | @State private var content: String | |
| 107 | ||
| 108 | init(heading: String, submitLabel: String, fileName: String, showsSettings: Bool, | |
| 109 | initialContent: String = "", working: Bool, errorMessage: String?, | |
| 110 | onSubmit: @escaping (String, String, String, String) -> Void) { | |
| 111 | self.heading = heading | |
| 112 | self.submitLabel = submitLabel | |
| 113 | self.fileName = fileName | |
| 114 | self.showsSettings = showsSettings | |
| 115 | self.working = working | |
| 116 | self.errorMessage = errorMessage | |
| 117 | self.onSubmit = onSubmit | |
| 118 | _name = State(initialValue: fileName) | |
| 119 | _content = State(initialValue: initialContent) | |
| 120 | } | |
| 121 | ||
| 122 | private var nameFixed: Bool { !fileName.isEmpty } | |
| 123 | ||
| 124 | var body: some View { | |
| 125 | NavigationStack { | |
| 126 | Form { | |
| 127 | Section { | |
| 128 | if nameFixed { | |
| 129 | Text(fileName) | |
| 130 | .font(.gbMono(.subheadline)) | |
| 131 | } else { | |
| 132 | TextField("File name", text: $name) | |
| 133 | .font(.gbMono(.subheadline)) | |
| 134 | .autocorrectionDisabled() | |
| 135 | .textInputAutocapitalization(.never) | |
| 136 | .accessibilityIdentifier("snippet-file-name") | |
| 137 | } | |
| 138 | if showsSettings { | |
| 139 | TextField("Description (optional)", text: $description) | |
| 140 | .autocorrectionDisabled() | |
| 141 | .accessibilityIdentifier("snippet-description") | |
| 142 | Picker("Visibility", selection: $visibility) { | |
| 143 | Text("unlisted").tag("unlisted") | |
| 144 | Text("public").tag("public") | |
| 145 | Text("private").tag("private") | |
| 146 | } | |
| 147 | .pickerStyle(.segmented) | |
| 148 | } | |
| 149 | } footer: { | |
| 150 | if !nameFixed { | |
| 151 | Text("Letters, digits and ._+- ; not starting with a dot.") | |
| 152 | } | |
| 153 | } | |
| 154 | Section { | |
| 155 | TextEditor(text: $content) | |
| 156 | .frame(minHeight: 200) | |
| 157 | .font(.gbMono(.caption)) | |
| 158 | .autocorrectionDisabled() | |
| 159 | .textInputAutocapitalization(.never) | |
| 160 | .accessibilityIdentifier("snippet-content") | |
| 161 | } | |
| 162 | if let errorMessage { | |
| 163 | Section { | |
| 164 | GBNotice(errorMessage) | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | .navigationTitle(heading) | |
| 169 | .navigationBarTitleDisplayMode(.inline) | |
| 170 | .toolbar { | |
| 171 | ToolbarItem(placement: .cancellationAction) { | |
| 172 | Button("Cancel") { dismiss() } | |
| 173 | .disabled(working) | |
| 174 | } | |
| 175 | ToolbarItem(placement: .confirmationAction) { | |
| 176 | if working { | |
| 177 | ProgressView() | |
| 178 | } else { | |
| 179 | Button(submitLabel) { | |
| 180 | onSubmit(name, description, visibility, content) | |
| 181 | } | |
| 182 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || content.isEmpty) | |
| 183 | .accessibilityIdentifier("snippet-submit") | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | .interactiveDismissDisabled(working) | |
| 188 | } | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | nonisolated enum SnippetRoute: Hashable { | |
| 193 | /// nil is the caller's own. | |
| 194 | case list(owner: String?) | |
| 195 | case snippet(id: String) | |
| 196 | } | |
gitbay/Views/Snippets/SnippetView.swift added +253
| @@ -0,0 +1,253 @@ | ||
| 1 | import 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. | |
| 6 | struct 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 | .swipeActions { | |
| 179 | if isOwner { | |
| 180 | Button("Remove", role: .destructive) { removingFile = file } | |
| 181 | Button("Edit") { editingFile = file } | |
| 182 | .tint(.gbAccent) | |
| 183 | } | |
| 184 | } | |
| 185 | } header: { | |
| 186 | HStack { | |
| 187 | Text(file.name) | |
| 188 | .font(.gbMono(.caption)) | |
| 189 | .textCase(nil) | |
| 190 | Spacer() | |
| 191 | Text(Int64(file.size).formatted(.byteCount(style: .file))) | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | private func code(_ file: SnippetFile) -> some View { | |
| 197 | let text = file.content ?? "" | |
| 198 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize | |
| 199 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) | |
| 200 | ?? .monospacedSystemFont(ofSize: size, weight: .regular) | |
| 201 | let highlighter = SyntaxHighlighter(colorScheme: colorScheme) | |
| 202 | if let highlighted = highlighter.highlight(text, fileName: file.name, font: font) { | |
| 203 | return Text(AttributedString(highlighted)) | |
| 204 | } | |
| 205 | return Text(text).font(.gbMono(.caption)) | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | /// `snippet edit`: description and visibility. | |
| 210 | private struct SnippetSettingsSheet: View { | |
| 211 | ||
| 212 | @Binding var description: String | |
| 213 | @Binding var visibility: String | |
| 214 | let working: Bool | |
| 215 | let errorMessage: String? | |
| 216 | let onSubmit: () -> Void | |
| 217 | ||
| 218 | @Environment(\.dismiss) private var dismiss | |
| 219 | ||
| 220 | var body: some View { | |
| 221 | NavigationStack { | |
| 222 | Form { | |
| 223 | TextField("Description", text: $description) | |
| 224 | .autocorrectionDisabled() | |
| 225 | Picker("Visibility", selection: $visibility) { | |
| 226 | Text("unlisted").tag("unlisted") | |
| 227 | Text("public").tag("public") | |
| 228 | Text("private").tag("private") | |
| 229 | } | |
| 230 | .pickerStyle(.segmented) | |
| 231 | if let errorMessage { | |
| 232 | Section { GBNotice(errorMessage) } | |
| 233 | } | |
| 234 | } | |
| 235 | .navigationTitle("Snippet Settings") | |
| 236 | .navigationBarTitleDisplayMode(.inline) | |
| 237 | .toolbar { | |
| 238 | ToolbarItem(placement: .cancellationAction) { | |
| 239 | Button("Cancel") { dismiss() } | |
| 240 | .disabled(working) | |
| 241 | } | |
| 242 | ToolbarItem(placement: .confirmationAction) { | |
| 243 | if working { | |
| 244 | ProgressView() | |
| 245 | } else { | |
| 246 | Button("Save", action: onSubmit) | |
| 247 | } | |
| 248 | } | |
| 249 | } | |
| 250 | .interactiveDismissDisabled(working) | |
| 251 | } | |
| 252 | } | |
| 253 | } | |
gitbayTests/SnippetTests.swift added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | |
| 18 | } | |
| 19 | ||
| 20 | private let snippetRow = """ | |
| 21 | {"id":"196505b81127","url":"https://gitbay.org/cmc/-/snippets/196505b81127","owner":"cmc",\ | |
| 22 | "description":"bootstrap file for homebrew","visibility":"public",\ | |
| 23 | "created_at":"2026-09-13T18:27:00.989Z","updated_at":"2026-09-13T18:27:00.989Z",\ | |
| 24 | "files":[{"name":"brew.sh","size":812}]} | |
| 25 | """ | |
| 26 | private let bareRow = """ | |
| 27 | {"id":"aa11bb22cc33","url":"https://gitbay.org/cmc/-/snippets/aa11bb22cc33","owner":"cmc",\ | |
| 28 | "visibility":"unlisted","created_at":"2026-09-14T10:00:00Z","updated_at":"2026-09-14T10:00:00Z",\ | |
| 29 | "files":[{"name":"notes.txt","size":4},{"name":"more.txt","size":5}]} | |
| 30 | """ | |
| 31 | private let listPageJSON = """ | |
| 32 | {"protocol_version":1,"data":{"items":[\(snippetRow),\(bareRow)]},"exit_code":0} | |
| 33 | """ | |
| 34 | private let showJSON = """ | |
| 35 | {"protocol_version":1,"data":{"id":"196505b81127","url":"https://gitbay.org/cmc/-/snippets/196505b81127",\ | |
| 36 | "owner":"cmc","description":"bootstrap file for homebrew","visibility":"public",\ | |
| 37 | "created_at":"2026-09-13T18:27:00.989Z","updated_at":"2026-09-13T18:27:00.989Z",\ | |
| 38 | "files":[{"name":"brew.sh","size":812,"content":"#!/usr/bin/env bash\\necho hi\\n"}]},"exit_code":0} | |
| 39 | """ | |
| 40 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 41 | ||
| 42 | struct SnippetDecodingTests { | |
| 43 | ||
| 44 | @Test func aListRowCarriesFileNamesAndSizesButNoContent() throws { | |
| 45 | let snippet = try GitbayClient.decoder().decode(Snippet.self, from: Data(snippetRow.utf8)) | |
| 46 | #expect(snippet.id == "196505b81127") | |
| 47 | #expect(snippet.owner == "cmc") | |
| 48 | #expect(snippet.visibility == "public") | |
| 49 | #expect(snippet.files.map(\.name) == ["brew.sh"]) | |
| 50 | #expect(snippet.files[0].size == 812) | |
| 51 | #expect(snippet.files[0].content == nil) | |
| 52 | #expect(snippet.title == "bootstrap file for homebrew") | |
| 53 | } | |
| 54 | ||
| 55 | /// The web titles a snippet by its description and falls back to the | |
| 56 | /// id; the description key is omitempty. | |
| 57 | @Test func withoutADescriptionTheTitleIsTheId() throws { | |
| 58 | let snippet = try GitbayClient.decoder().decode(Snippet.self, from: Data(bareRow.utf8)) | |
| 59 | #expect(snippet.description == nil) | |
| 60 | #expect(snippet.title == "aa11bb22cc33") | |
| 61 | #expect(snippet.fileNames == "notes.txt, more.txt") | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | @MainActor | |
| 66 | struct SnippetListViewModelTests { | |
| 67 | ||
| 68 | @Test func mineListsWithoutAnOwner() async throws { | |
| 69 | let (client, stub) = try makeClient() | |
| 70 | stub.enqueue(.init(status: 200, json: listPageJSON)) | |
| 71 | let model = SnippetListViewModel(client: client, owner: nil) | |
| 72 | ||
| 73 | await model.load() | |
| 74 | ||
| 75 | #expect(stub.seen.first?.url.query()?.hasPrefix("argv=snippet&argv=list&argv=--limit") == true) | |
| 76 | #expect(model.state.value?.map(\.id) == ["196505b81127", "aa11bb22cc33"]) | |
| 77 | } | |
| 78 | ||
| 79 | @Test func anOwnersListNamesTheOwner() async throws { | |
| 80 | let (client, stub) = try makeClient() | |
| 81 | stub.enqueue(.init(status: 200, json: listPageJSON)) | |
| 82 | let model = SnippetListViewModel(client: client, owner: "cmc") | |
| 83 | ||
| 84 | await model.load() | |
| 85 | ||
| 86 | #expect(stub.seen.first?.url.query()?.hasPrefix("argv=snippet&argv=list&argv=cmc&argv=--limit") == true) | |
| 87 | } | |
| 88 | ||
| 89 | /// `snippet create <filename> [--description] [--visibility] < file`: | |
| 90 | /// the file travels as bare stdin, an empty description sends no | |
| 91 | /// flag, and the created snippet comes back. | |
| 92 | @Test func createSendsTheFileOnStdinAndReturnsTheSnippet() async throws { | |
| 93 | let (client, stub) = try makeClient() | |
| 94 | stub.enqueue(.init(status: 200, json: listPageJSON)) | |
| 95 | let model = SnippetListViewModel(client: client, owner: nil) | |
| 96 | await model.load() | |
| 97 | stub.enqueue(.init(status: 200, json: showJSON, match: "cmd")) | |
| 98 | stub.enqueue(.init(status: 200, json: listPageJSON, match: "argv=list")) | |
| 99 | ||
| 100 | let created = await model.create( | |
| 101 | filename: "brew.sh", description: "", visibility: "public", content: "echo hi\n") | |
| 102 | ||
| 103 | #expect(created?.id == "196505b81127") | |
| 104 | let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) | |
| 105 | #expect(argv == ["snippet", "create", "brew.sh", "--visibility", "public"]) | |
| 106 | #expect(stdin == "echo hi\n") | |
| 107 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | |
| 108 | } | |
| 109 | ||
| 110 | @Test func aRefusedCreateSurfacesTheRule() async throws { | |
| 111 | let (client, stub) = try makeClient() | |
| 112 | stub.enqueue(.init(status: 200, json: listPageJSON)) | |
| 113 | let model = SnippetListViewModel(client: client, owner: nil) | |
| 114 | await model.load() | |
| 115 | stub.enqueue(.init(status: 200, json: """ | |
| 116 | {"protocol_version":1,"error":"snippet limit reached (20); delete one first","exit_code":2} | |
| 117 | """, match: "cmd")) | |
| 118 | ||
| 119 | let created = await model.create( | |
| 120 | filename: "x.txt", description: "d", visibility: "unlisted", content: "x") | |
| 121 | ||
| 122 | #expect(created == nil) | |
| 123 | #expect(model.actionError == "snippet limit reached (20); delete one first") | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | @MainActor | |
| 128 | struct SnippetDetailViewModelTests { | |
| 129 | ||
| 130 | private func loaded() async throws -> (SnippetDetailViewModel, StubProtocol.Box) { | |
| 131 | let (client, stub) = try makeClient() | |
| 132 | stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show")) | |
| 133 | let model = SnippetDetailViewModel(client: client, id: "196505b81127") | |
| 134 | await model.load() | |
| 135 | return (model, stub) | |
| 136 | } | |
| 137 | ||
| 138 | @Test func showCarriesEveryFilesContent() async throws { | |
| 139 | let (model, stub) = try await loaded() | |
| 140 | #expect(stub.seen.first?.url.query() == "argv=snippet&argv=show&argv=196505b81127") | |
| 141 | let snippet = try #require(model.state.value) | |
| 142 | #expect(snippet.files[0].content == "#!/usr/bin/env bash\necho hi\n") | |
| 143 | } | |
| 144 | ||
| 145 | @Test func editSendsOnlyTheFieldsGiven() async throws { | |
| 146 | let (model, stub) = try await loaded() | |
| 147 | for _ in 0..<2 { | |
| 148 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 149 | stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show")) | |
| 150 | } | |
| 151 | ||
| 152 | await model.edit(description: "renamed", visibility: "private") | |
| 153 | await model.edit(description: nil, visibility: "unlisted") | |
| 154 | ||
| 155 | let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } | |
| 156 | #expect(writes == [ | |
| 157 | ["snippet", "edit", "196505b81127", "--description", "renamed", "--visibility", "private"], | |
| 158 | ["snippet", "edit", "196505b81127", "--visibility", "unlisted"], | |
| 159 | ]) | |
| 160 | } | |
| 161 | ||
| 162 | @Test func fileSetTravelsAsStdinAndRemoveNamesTheFile() async throws { | |
| 163 | let (model, stub) = try await loaded() | |
| 164 | for _ in 0..<2 { | |
| 165 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 166 | stub.enqueue(.init(status: 200, json: showJSON, match: "argv=show")) | |
| 167 | } | |
| 168 | ||
| 169 | await model.setFile("notes.txt", content: "hello\n") | |
| 170 | await model.removeFile("notes.txt") | |
| 171 | ||
| 172 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 173 | #expect(writes[0].0 == ["snippet", "file", "set", "196505b81127", "notes.txt"]) | |
| 174 | #expect(writes[0].1 == "hello\n") | |
| 175 | #expect(writes[1].0 == ["snippet", "file", "remove", "196505b81127", "notes.txt"]) | |
| 176 | } | |
| 177 | ||
| 178 | @Test func deleteReportsSuccessAndDoesNotReload() async throws { | |
| 179 | let (model, stub) = try await loaded() | |
| 180 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 181 | ||
| 182 | let ok = await model.delete() | |
| 183 | ||
| 184 | #expect(ok) | |
| 185 | #expect(try argvOf(try #require(stub.seen.first { $0.method == "POST" })).0 | |
| 186 | == ["snippet", "delete", "196505b81127"]) | |
| 187 | #expect(stub.seen.filter { $0.method == "GET" }.count == 1) | |
| 188 | } | |
| 189 | } | |