Commit b96f8005cf

b96f8005cff11b4eafbb3501ab229034fc314d92

parent: 6a01f20b03

Unsigned

cmc <hello@cleberg.net> · 2026-09-19 03:00 UTC

Snippets (!104)

Parity rows "snippet create, edit, delete", "snippet show, list" and "snippet file set, get, remove" (krz/gitbay 51479b2). List (own at every visibility, or an owner's public ones, paginated), detail with every file highlighted, and the owner's writes: create, file set, file remove, edit, delete. `file get` is covered by `show` carrying content. Entry points: account menu and a user's profile.

Layout: unified · split

gitbay/ContentView.swift +13
@@ -75,6 +75,9 @@ private struct RouteDestinations: ViewModifier {
7575 .navigationDestination(for: OrgRoute.self) { route in
7676 destination(route, client: client)
7777 }
78 .navigationDestination(for: SnippetRoute.self) { route in
79 destination(route, client: client)
80 }
7881 }
7982
8083 @ViewBuilder
@@ -163,6 +166,16 @@ private struct RouteDestinations: ViewModifier {
163166 }
164167 }
165168
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
166179 @ViewBuilder
167180 private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
168181 switch route {
gitbay/Snippets/SnippetDetailViewModel.swift added +85
@@ -0,0 +1,85 @@
1import Foundation
2import 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
9final 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 @@
1import Foundation
2import 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
8final 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 @@
1import Foundation
2
3/// One file of a snippet. `content` rides only on `snippet show`; a list
4/// row carries the name and size.
5nonisolated 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`.
14nonisolated 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 {
3838 Label("Members & Teams", systemImage: "person.3")
3939 }
4040 }
41 } else {
42 Section {
43 NavigationLink(value: SnippetRoute.list(owner: profile.name)) {
44 Label("Snippets", systemImage: "scissors")
45 }
46 }
4147 }
4248 }
4349 }
gitbay/Views/Repos/RepoListView.swift +3
@@ -262,6 +262,9 @@ struct AccountMenu: ToolbarContent {
262262 NavigationLink(value: RepoRoute.account) {
263263 Label("Keys & Email", systemImage: "key")
264264 }
265 NavigationLink(value: SnippetRoute.list(owner: nil)) {
266 Label("Snippets", systemImage: "scissors")
267 }
265268 }
266269 }
267270 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
gitbay/Views/Snippets/SnippetListView.swift added +196
@@ -0,0 +1,196 @@
1import SwiftUI
2
3/// Your snippets at every visibility, or an owner's public ones.
4struct 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
60struct 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.
91struct 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
192nonisolated 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 @@
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 .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.
210private 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 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private 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
20private 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 """
26private 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 """
31private let listPageJSON = """
32 {"protocol_version":1,"data":{"items":[\(snippetRow),\(bareRow)]},"exit_code":0}
33 """
34private 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 """
40private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
41
42struct 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
66struct 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
128struct 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}