Commit b96f8005cf
Unsigned
Layout: unified · split
gitbay/ContentView.swift +13
| @@ -75,6 +75,9 @@ private struct RouteDestinations: ViewModifier { | |||
| 75 | .navigationDestination(for: OrgRoute.self) { route in | 75 | .navigationDestination(for: OrgRoute.self) { route in |
| 76 | destination(route, client: client) | 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 | @ViewBuilder | 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 | @ViewBuilder | 179 | @ViewBuilder |
| 167 | private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View { | 180 | private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View { |
| 168 | switch route { | 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 | Label("Members & Teams", systemImage: "person.3") | 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 | NavigationLink(value: RepoRoute.account) { | 262 | NavigationLink(value: RepoRoute.account) { |
| 263 | Label("Keys & Email", systemImage: "key") | 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 | if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { | 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 | } | ||