Commit 563215175a
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -319,6 +319,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 319 | 319 | LabelListView(client: client, repo: repo) |
| 320 | 320 | case .bookmarks: |
| 321 | 321 | BookmarkListView(client: client) |
| 322 | case .savedQueries: | |
| 323 | SavedQueriesView(client: client) | |
| 324 | case .queryResults(let name): | |
| 325 | QueryResultsView(client: client, name: name) | |
| 322 | 326 | case .webhooks(let repo): |
| 323 | 327 | WebhookListView(client: client, repo: repo) |
| 324 | 328 | case .webhookDeliveries(let repo): |
gitbay/Dashboard/DashboardModels.swift +4 −1
| @@ -17,9 +17,11 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | ||
| 17 | 17 | /// The unread notification count, so a client showing a badge does |
| 18 | 18 | /// not need a second read to fill it. |
| 19 | 19 | let unread: Int |
| 20 | /// Pinned saved queries with their first rows (gitbay v1.39.0). | |
| 21 | let queries: [DashboardQuery] | |
| 20 | 22 | |
| 21 | 23 | enum CodingKeys: String, CodingKey { |
| 22 | case pinned, builds, unread | |
| 24 | case pinned, builds, unread, queries | |
| 23 | 25 | case reviewQueue = "review_queue" |
| 24 | 26 | case openMRs = "open_mrs" |
| 25 | 27 | case assignedIssues = "assigned_issues" |
| @@ -37,6 +39,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable { | ||
| 37 | 39 | recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] |
| 38 | 40 | builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] |
| 39 | 41 | unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0 |
| 42 | queries = try values.decodeIfPresent([DashboardQuery].self, forKey: .queries) ?? [] | |
| 40 | 43 | } |
| 41 | 44 | } |
| 42 | 45 | |
gitbay/Discovery/SavedQuery.swift added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One saved query (`query list`, gitbay v1.39.0): an issue and merge | |
| 5 | /// request query across repositories, kept under a name. | |
| 6 | nonisolated struct SavedQuery: Decodable, Sendable, Hashable, Identifiable { | |
| 7 | let name: String | |
| 8 | let query: String | |
| 9 | let pinned: Bool | |
| 10 | var id: String { name } | |
| 11 | } | |
| 12 | ||
| 13 | /// A row of `query run` and of a pinned query on the dashboard: an issue | |
| 14 | /// or merge request, naming its repository. | |
| 15 | nonisolated struct QueryItem: Decodable, Sendable, Hashable, Identifiable { | |
| 16 | let kind: String | |
| 17 | let repo: String | |
| 18 | let number: Int64 | |
| 19 | let title: String | |
| 20 | let state: String | |
| 21 | let draft: Bool? | |
| 22 | let author: String | |
| 23 | let milestone: String? | |
| 24 | let createdAt: Date | |
| 25 | let updatedAt: Date | |
| 26 | ||
| 27 | enum CodingKeys: String, CodingKey { | |
| 28 | case kind, repo, number, title, state, draft, author, milestone | |
| 29 | case createdAt = "created_at" | |
| 30 | case updatedAt = "updated_at" | |
| 31 | } | |
| 32 | ||
| 33 | var isMR: Bool { kind == "mr" } | |
| 34 | /// owner/name#n or owner/name!n, as a person writes it. | |
| 35 | var ref: String { repo + (isMR ? "!" : "#") + String(number) } | |
| 36 | var id: String { ref } | |
| 37 | ||
| 38 | var route: any Hashable { | |
| 39 | isMR ? MRRoute.mr(repo: repo, number: number) as any Hashable | |
| 40 | : IssueRoute.issue(repo: repo, number: number) | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | /// A pinned query on the dashboard: its first rows and how many it | |
| 45 | /// matches in all. `error` is set when the saved text no longer parses. | |
| 46 | nonisolated struct DashboardQuery: Decodable, Sendable, Hashable, Identifiable { | |
| 47 | let name: String | |
| 48 | let query: String | |
| 49 | let count: Int | |
| 50 | let items: [QueryItem] | |
| 51 | let error: String? | |
| 52 | var id: String { name } | |
| 53 | } | |
| 54 | ||
| 55 | /// `query list`, and the writes on it: save, pin, unpin, remove. | |
| 56 | @Observable | |
| 57 | @MainActor | |
| 58 | final class SavedQueriesViewModel { | |
| 59 | ||
| 60 | private(set) var state: LoadState<[SavedQuery]> = .loading | |
| 61 | private(set) var actionError: String? | |
| 62 | private(set) var working = false | |
| 63 | ||
| 64 | private let client: GitbayClient | |
| 65 | ||
| 66 | init(client: GitbayClient) { | |
| 67 | self.client = client | |
| 68 | } | |
| 69 | ||
| 70 | func load() async { | |
| 71 | do { | |
| 72 | let queries = try await client.readList(["query", "list"], of: SavedQuery.self) | |
| 73 | state = queries.isEmpty | |
| 74 | ? .empty("No saved queries. Save one from the + button.") | |
| 75 | : .loaded(queries) | |
| 76 | } catch { | |
| 77 | state = .from(error) | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /// `query save <name> <query> [--force]`. The query goes as one word; | |
| 82 | /// the server reads it the same as many. Returns whether it saved. | |
| 83 | func save(name: String, query: String, replace: Bool) async -> Bool { | |
| 84 | var argv = ["query", "save", name.trimmingCharacters(in: .whitespaces), | |
| 85 | query.trimmingCharacters(in: .whitespacesAndNewlines)] | |
| 86 | if replace { argv.append("--force") } | |
| 87 | return await perform(argv) | |
| 88 | } | |
| 89 | ||
| 90 | func setPinned(_ query: SavedQuery, _ pinned: Bool) async { | |
| 91 | await perform(["query", pinned ? "pin" : "unpin", query.name]) | |
| 92 | } | |
| 93 | ||
| 94 | func remove(_ query: SavedQuery) async { | |
| 95 | await perform(["query", "remove", query.name]) | |
| 96 | } | |
| 97 | ||
| 98 | @discardableResult | |
| 99 | private func perform(_ argv: [String]) async -> Bool { | |
| 100 | working = true | |
| 101 | actionError = nil | |
| 102 | defer { working = false } | |
| 103 | do { | |
| 104 | try await client.run(argv) | |
| 105 | } catch let error as GitbayError { | |
| 106 | // The name and the query are typed, so a usage refusal is theirs. | |
| 107 | actionError = error.messageForTypedInput | |
| 108 | return false | |
| 109 | } catch { | |
| 110 | actionError = GitbayError.transport(error).userFacingMessage | |
| 111 | return false | |
| 112 | } | |
| 113 | await load() | |
| 114 | return true | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /// `query run <name>`, paged. | |
| 119 | @Observable | |
| 120 | @MainActor | |
| 121 | final class QueryResultsViewModel { | |
| 122 | ||
| 123 | let list: PagedListModel<QueryItem> | |
| 124 | let name: String | |
| 125 | ||
| 126 | init(client: GitbayClient, name: String) { | |
| 127 | self.name = name | |
| 128 | list = PagedListModel(client: client, argv: ["query", "run", name], emptyMessage: "Nothing matches.") | |
| 129 | } | |
| 130 | ||
| 131 | func load() async { | |
| 132 | await list.reload() | |
| 133 | } | |
| 134 | } | |
gitbay/Networking/GitbayError.swift +8
| @@ -55,6 +55,14 @@ nonisolated enum GitbayError: LocalizedError, Sendable { | ||
| 55 | 55 | |
| 56 | 56 | var errorDescription: String? { userFacingMessage } |
| 57 | 57 | |
| 58 | /// `userFacingMessage` for a command whose argument the user typed: a | |
| 59 | /// query, a name, a check's name. A usage refusal is then about what | |
| 60 | /// they wrote, not the app's argv, and its message says what to fix. | |
| 61 | var messageForTypedInput: String { | |
| 62 | if case .usage(let message) = self, !message.isEmpty { return message } | |
| 63 | return userFacingMessage | |
| 64 | } | |
| 65 | ||
| 58 | 66 | /// True when the right response is an empty screen rather than a banner. |
| 59 | 67 | var isEmptyState: Bool { |
| 60 | 68 | if case .notFound = self { return true } |
gitbay/Repos/RepoSettingsViewModel.swift +4 −3
| @@ -130,8 +130,9 @@ final class RepoSettingsViewModel { | ||
| 130 | 130 | await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context }) |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | /// A check's name is typed, so the server's refusal of one is shown. | |
| 133 | 134 | private func setRequiredContexts(_ contexts: [String]) async { |
| 134 | await perform(["repo", "settings", "require-contexts", repoPath] + contexts) | |
| 135 | await perform(["repo", "settings", "require-contexts", repoPath] + contexts, typed: true) { await load() } | |
| 135 | 136 | } |
| 136 | 137 | |
| 137 | 138 | func setRequireSigned(_ on: Bool) async { |
| @@ -226,7 +227,7 @@ final class RepoSettingsViewModel { | ||
| 226 | 227 | await perform(argv, reload: load) |
| 227 | 228 | } |
| 228 | 229 | |
| 229 | private func perform(_ argv: [String], reload: () async -> Void) async { | |
| 230 | private func perform(_ argv: [String], typed: Bool = false, reload: () async -> Void) async { | |
| 230 | 231 | working = true |
| 231 | 232 | actionError = nil |
| 232 | 233 | defer { working = false } |
| @@ -234,7 +235,7 @@ final class RepoSettingsViewModel { | ||
| 234 | 235 | try await client.run(argv) |
| 235 | 236 | await reload() |
| 236 | 237 | } catch let error as GitbayError { |
| 237 | actionError = error.userFacingMessage | |
| 238 | actionError = typed ? error.messageForTypedInput : error.userFacingMessage | |
| 238 | 239 | } catch { |
| 239 | 240 | actionError = GitbayError.transport(error).userFacingMessage |
| 240 | 241 | } |
gitbay/Views/Dashboard/DashboardView.swift +47
| @@ -40,6 +40,10 @@ struct DashboardView: View { | ||
| 40 | 40 | empty: "No open issues", marker: "#" |
| 41 | 41 | ) { IssueRoute.issue(repo: $0.repo, number: $0.number) } |
| 42 | 42 | |
| 43 | ForEach(data.queries) { query in | |
| 44 | querySection(query) | |
| 45 | } | |
| 46 | ||
| 43 | 47 | pinnedSection(data.pinned) |
| 44 | 48 | activitySection(data.recentActivity) |
| 45 | 49 | } |
| @@ -47,6 +51,13 @@ struct DashboardView: View { | ||
| 47 | 51 | .overlay { LoadStateOverlay(state: model.state) } |
| 48 | 52 | .navigationTitle("Dashboard") |
| 49 | 53 | .toolbar { |
| 54 | ToolbarItem(placement: .topBarLeading) { | |
| 55 | RouteLink(value: RepoRoute.savedQueries) { | |
| 56 | Image(systemName: "line.3.horizontal.decrease.circle") | |
| 57 | } | |
| 58 | .accessibilityLabel("Saved Queries") | |
| 59 | .accessibilityIdentifier("dashboard-queries-button") | |
| 60 | } | |
| 50 | 61 | ToolbarItem(placement: .topBarTrailing) { |
| 51 | 62 | RouteLink(value: RepoRoute.notifications) { |
| 52 | 63 | HStack(spacing: 2) { |
| @@ -140,6 +151,42 @@ struct DashboardView: View { | ||
| 140 | 151 | .padding(.vertical, 2) |
| 141 | 152 | } |
| 142 | 153 | |
| 154 | /// A pinned saved query: its first rows, and the rest behind its name, | |
| 155 | /// as the web dashboard shows it. | |
| 156 | private func querySection(_ query: DashboardQuery) -> some View { | |
| 157 | Section { | |
| 158 | if let error = query.error { | |
| 159 | Text(error) | |
| 160 | .font(.gbSans(.subheadline)) | |
| 161 | .foregroundStyle(.secondary) | |
| 162 | } else if query.items.isEmpty { | |
| 163 | Text("Nothing matches") | |
| 164 | .font(.gbSans(.subheadline)) | |
| 165 | .foregroundStyle(.secondary) | |
| 166 | } else { | |
| 167 | ForEach(query.items) { item in | |
| 168 | NavigationLink(value: item.route) { | |
| 169 | QueryItemRow(item: item) | |
| 170 | } | |
| 171 | } | |
| 172 | if query.count > query.items.count { | |
| 173 | NavigationLink(value: RepoRoute.queryResults(name: query.name)) { | |
| 174 | Text("All \(query.count)") | |
| 175 | .font(.gbSans(.subheadline)) | |
| 176 | .foregroundStyle(Color.gbAccent) | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | } header: { | |
| 181 | VStack(alignment: .leading, spacing: 2) { | |
| 182 | sectionHeader(query.name, count: query.count) | |
| 183 | Text(query.query) | |
| 184 | .font(.gbMono(.caption2)) | |
| 185 | .textCase(nil) | |
| 186 | } | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 143 | 190 | private func pinnedSection(_ pinned: [RepoSummary]) -> some View { |
| 144 | 191 | Section { |
| 145 | 192 | if pinned.isEmpty { |
gitbay/Views/Discovery/SavedQueriesView.swift added +236
| @@ -0,0 +1,236 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Your saved queries: open one for its results, swipe to pin it to the | |
| 4 | /// dashboard or remove it, and save a new one from the + button. | |
| 5 | struct SavedQueriesView: View { | |
| 6 | ||
| 7 | @State private var model: SavedQueriesViewModel | |
| 8 | @State private var composing = false | |
| 9 | @State private var removing: SavedQuery? | |
| 10 | ||
| 11 | init(client: GitbayClient) { | |
| 12 | _model = State(initialValue: SavedQueriesViewModel(client: client)) | |
| 13 | } | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | List { | |
| 17 | if let error = model.actionError, !composing { | |
| 18 | Section { GBNotice(error, .gbWarn) } | |
| 19 | } | |
| 20 | ForEach(model.state.value ?? []) { query in | |
| 21 | NavigationLink(value: RepoRoute.queryResults(name: query.name)) { | |
| 22 | VStack(alignment: .leading, spacing: 3) { | |
| 23 | HStack(spacing: 6) { | |
| 24 | Text(query.name) | |
| 25 | .font(.gbSans(.subheadline).weight(.semibold)) | |
| 26 | if query.pinned { | |
| 27 | GBChip("pinned", .secondary) | |
| 28 | } | |
| 29 | } | |
| 30 | Text(query.query) | |
| 31 | .font(.gbMono(.caption)) | |
| 32 | .foregroundStyle(.secondary) | |
| 33 | .lineLimit(2) | |
| 34 | } | |
| 35 | .accessibilityElement(children: .combine) | |
| 36 | } | |
| 37 | .accessibilityIdentifier("saved-query-\(query.name)") | |
| 38 | .swipeActions(edge: .trailing) { | |
| 39 | Button(role: .destructive) { | |
| 40 | removing = query | |
| 41 | } label: { | |
| 42 | Label("Remove", systemImage: "trash") | |
| 43 | } | |
| 44 | Button { | |
| 45 | Task { await model.setPinned(query, !query.pinned) } | |
| 46 | } label: { | |
| 47 | Label(query.pinned ? "Unpin" : "Pin", systemImage: query.pinned ? "pin.slash" : "pin") | |
| 48 | } | |
| 49 | .tint(Color.gbAccent) | |
| 50 | } | |
| 51 | .contextMenu { | |
| 52 | Button { | |
| 53 | Task { await model.setPinned(query, !query.pinned) } | |
| 54 | } label: { | |
| 55 | Label(query.pinned ? "Unpin from Dashboard" : "Pin to Dashboard", | |
| 56 | systemImage: query.pinned ? "pin.slash" : "pin") | |
| 57 | } | |
| 58 | Button(role: .destructive) { | |
| 59 | removing = query | |
| 60 | } label: { | |
| 61 | Label("Remove", systemImage: "trash") | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | .overlay { LoadStateOverlay(state: model.state) } | |
| 67 | .navigationTitle("Saved Queries") | |
| 68 | .navigationBarTitleDisplayMode(.inline) | |
| 69 | .toolbar { | |
| 70 | ToolbarItem(placement: .topBarTrailing) { | |
| 71 | Button { | |
| 72 | composing = true | |
| 73 | } label: { | |
| 74 | Image(systemName: "plus") | |
| 75 | } | |
| 76 | .accessibilityLabel("Save a Query") | |
| 77 | .accessibilityIdentifier("saved-query-create") | |
| 78 | } | |
| 79 | } | |
| 80 | .confirmationDialog( | |
| 81 | removing.map { "Remove \($0.name)?" } ?? "", | |
| 82 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | |
| 83 | titleVisibility: .visible, | |
| 84 | presenting: removing | |
| 85 | ) { query in | |
| 86 | Button("Remove", role: .destructive) { Task { await model.remove(query) } } | |
| 87 | Button("Cancel", role: .cancel) {} | |
| 88 | } | |
| 89 | .sheet(isPresented: $composing) { | |
| 90 | SaveQuerySheet(model: model) { composing = false } | |
| 91 | } | |
| 92 | .task { await model.load() } | |
| 93 | .refreshable { await model.load() } | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | private struct SaveQuerySheet: View { | |
| 98 | ||
| 99 | let model: SavedQueriesViewModel | |
| 100 | let onSaved: () -> Void | |
| 101 | ||
| 102 | @State private var name = "" | |
| 103 | @State private var query = "" | |
| 104 | @State private var replace = false | |
| 105 | @State private var sent = false | |
| 106 | @Environment(\.dismiss) private var dismiss | |
| 107 | ||
| 108 | var body: some View { | |
| 109 | NavigationStack { | |
| 110 | Form { | |
| 111 | Section("Name") { | |
| 112 | TextField("mine", text: $name) | |
| 113 | .font(.gbMono(.body)) | |
| 114 | .autocorrectionDisabled() | |
| 115 | .textInputAutocapitalization(.never) | |
| 116 | .accessibilityIdentifier("saved-query-name") | |
| 117 | } | |
| 118 | Section { | |
| 119 | TextField("is:open author:@me", text: $query, axis: .vertical) | |
| 120 | .font(.gbMono(.body)) | |
| 121 | .lineLimit(1...4) | |
| 122 | .autocorrectionDisabled() | |
| 123 | .textInputAutocapitalization(.never) | |
| 124 | .accessibilityIdentifier("saved-query-text") | |
| 125 | Toggle("Replace one with this name", isOn: $replace) | |
| 126 | .font(.gbSans(.subheadline)) | |
| 127 | } header: { | |
| 128 | Text("Query") | |
| 129 | } footer: { | |
| 130 | Text("Qualifiers: repo:, owner:, is:open, is:closed, is:merged, is:issue, is:mr, label:, no:label, milestone:, assignee: and author:, with @me for you. Other words search titles and bodies.") | |
| 131 | } | |
| 132 | if sent, let error = model.actionError { | |
| 133 | Section { GBNotice(error) } | |
| 134 | } | |
| 135 | } | |
| 136 | .navigationTitle("Save a Query") | |
| 137 | .navigationBarTitleDisplayMode(.inline) | |
| 138 | .toolbar { | |
| 139 | ToolbarItem(placement: .cancellationAction) { | |
| 140 | Button("Cancel") { dismiss() } | |
| 141 | } | |
| 142 | ToolbarItem(placement: .confirmationAction) { | |
| 143 | if model.working { | |
| 144 | ProgressView() | |
| 145 | } else { | |
| 146 | Button("Save") { | |
| 147 | sent = true | |
| 148 | Task { | |
| 149 | if await model.save(name: name, query: query, replace: replace) { onSaved() } | |
| 150 | } | |
| 151 | } | |
| 152 | .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty | |
| 153 | || query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 154 | .accessibilityIdentifier("saved-query-save") | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | .interactiveDismissDisabled(model.working) | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | /// What a saved query matches, newest first, as `query run` pages it. | |
| 164 | struct QueryResultsView: View { | |
| 165 | ||
| 166 | @State private var model: QueryResultsViewModel | |
| 167 | ||
| 168 | init(client: GitbayClient, name: String) { | |
| 169 | _model = State(initialValue: QueryResultsViewModel(client: client, name: name)) | |
| 170 | } | |
| 171 | ||
| 172 | var body: some View { | |
| 173 | List { | |
| 174 | ForEach(model.list.state.value ?? []) { item in | |
| 175 | NavigationLink(value: item.route) { | |
| 176 | QueryItemRow(item: item, showsState: true) | |
| 177 | } | |
| 178 | .onAppear { | |
| 179 | if item.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } } | |
| 180 | } | |
| 181 | } | |
| 182 | PageFooter(list: model.list) | |
| 183 | } | |
| 184 | .overlay { LoadStateOverlay(state: model.list.state) } | |
| 185 | .navigationTitle(model.name) | |
| 186 | .navigationBarTitleDisplayMode(.inline) | |
| 187 | .task { if model.list.needsLoad { await model.load() } } | |
| 188 | .refreshable { await model.load() } | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | /// One issue or merge request a query matched. | |
| 193 | struct QueryItemRow: View { | |
| 194 | ||
| 195 | let item: QueryItem | |
| 196 | var showsState = false | |
| 197 | ||
| 198 | var body: some View { | |
| 199 | VStack(alignment: .leading, spacing: 3) { | |
| 200 | HStack(alignment: .firstTextBaseline, spacing: 6) { | |
| 201 | Text(item.title) | |
| 202 | .font(.gbSans(.subheadline).weight(.semibold)) | |
| 203 | .lineLimit(2) | |
| 204 | if showsState { | |
| 205 | Spacer(minLength: 4) | |
| 206 | GBChip(item.draft == true ? "draft" : item.state, stateColor) | |
| 207 | } | |
| 208 | } | |
| 209 | HStack(spacing: 4) { | |
| 210 | Text(item.ref) | |
| 211 | Text("·") | |
| 212 | Text(item.author) | |
| 213 | .foregroundStyle(Color.gbAccent) | |
| 214 | if let milestone = item.milestone { | |
| 215 | Text("·") | |
| 216 | Text("in \(milestone)") | |
| 217 | } | |
| 218 | Text("·") | |
| 219 | Text(item.updatedAt, format: .relative(presentation: .named)) | |
| 220 | } | |
| 221 | .font(.gbSans(.caption)) | |
| 222 | .foregroundStyle(.secondary) | |
| 223 | .lineLimit(1) | |
| 224 | } | |
| 225 | .padding(.vertical, 2) | |
| 226 | } | |
| 227 | ||
| 228 | private var stateColor: Color { | |
| 229 | if item.draft == true { return .secondary } | |
| 230 | switch item.state { | |
| 231 | case "open": return .gbOK | |
| 232 | case "merged": return .gbDone | |
| 233 | default: return .gbBad | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -21,6 +21,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 21 | 21 | case wikiPage(repo: String, page: String) |
| 22 | 22 | case commit(repo: String, sha: String) |
| 23 | 23 | case bookmarks |
| 24 | case savedQueries | |
| 25 | case queryResults(name: String) | |
| 24 | 26 | case webhooks(repo: String) |
| 25 | 27 | case webhookDeliveries(repo: String) |
| 26 | 28 | case profile(String) |
gitbayTests/RequiredContextsTests.swift +2 −1
| @@ -74,7 +74,8 @@ struct RequiredContextsTests { | ||
| 74 | 74 | let cleared = await model.addRequiredContext("ci/more") |
| 75 | 75 | |
| 76 | 76 | #expect(!cleared) |
| 77 | #expect(model.actionError != nil) | |
| 77 | // The name was typed, so the refusal is shown as the server said it. | |
| 78 | #expect(model.actionError == "at most 20 required contexts") | |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | @Test func removingTheLastContextSendsAnEmptyList() async throws { |
gitbayTests/SavedQueryTests.swift added +153
| @@ -0,0 +1,153 @@ | ||
| 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 posted(_ stub: StubProtocol.Box) throws -> [[String]] { | |
| 16 | try stub.seen.filter { $0.method == "POST" }.map { seen in | |
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 18 | return try #require(body["argv"] as? [String]) | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | private func argv(_ url: URL) -> [String] { | |
| 23 | URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? [] | |
| 24 | } | |
| 25 | ||
| 26 | private let listJSON = """ | |
| 27 | {"protocol_version":1,"data":[{"name":"mine","query":"is:open author:@me","pinned":true},\ | |
| 28 | {"name":"bugs","query":"is:issue label:bug","pinned":false}],"exit_code":0} | |
| 29 | """ | |
| 30 | ||
| 31 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 32 | ||
| 33 | @MainActor | |
| 34 | struct SavedQueriesViewModelTests { | |
| 35 | ||
| 36 | private func loaded() async throws -> (SavedQueriesViewModel, StubProtocol.Box) { | |
| 37 | let (client, stub) = try makeClient() | |
| 38 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 39 | let model = SavedQueriesViewModel(client: client) | |
| 40 | await model.load() | |
| 41 | return (model, stub) | |
| 42 | } | |
| 43 | ||
| 44 | @Test func listsSavedQueries() async throws { | |
| 45 | let (model, stub) = try await loaded() | |
| 46 | #expect(model.state.value?.map(\.name) == ["mine", "bugs"]) | |
| 47 | #expect(model.state.value?.first?.pinned == true) | |
| 48 | #expect(argv(try #require(stub.seen.first).url) == ["query", "list"]) | |
| 49 | } | |
| 50 | ||
| 51 | @Test func savingSendsTheNameAndTheQueryAsOneWord() async throws { | |
| 52 | let (model, stub) = try await loaded() | |
| 53 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | |
| 54 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 55 | ||
| 56 | let saved = await model.save(name: " review ", query: #" is:mr label:"needs review" "#, replace: false) | |
| 57 | ||
| 58 | #expect(saved) | |
| 59 | #expect(try posted(stub) == [["query", "save", "review", #"is:mr label:"needs review""#]]) | |
| 60 | } | |
| 61 | ||
| 62 | @Test func replacingAddsForce() async throws { | |
| 63 | let (model, stub) = try await loaded() | |
| 64 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | |
| 65 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 66 | ||
| 67 | _ = await model.save(name: "bugs", query: "is:issue label:bug is:open", replace: true) | |
| 68 | ||
| 69 | #expect(try posted(stub) == [["query", "save", "bugs", "is:issue label:bug is:open", "--force"]]) | |
| 70 | } | |
| 71 | ||
| 72 | @Test func aRefusedSaveKeepsTheSheetAndSaysWhy() async throws { | |
| 73 | let (model, stub) = try await loaded() | |
| 74 | // A failure is retried once before it surfaces. | |
| 75 | for _ in 0..<2 { | |
| 76 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"you already have a query named bugs; pass --force to replace it","exit_code":1}"#, match: "cmd")) | |
| 77 | } | |
| 78 | ||
| 79 | let saved = await model.save(name: "bugs", query: "x", replace: false) | |
| 80 | ||
| 81 | #expect(!saved) | |
| 82 | #expect(model.actionError == "you already have a query named bugs; pass --force to replace it") | |
| 83 | } | |
| 84 | ||
| 85 | /// The query is typed, so a syntax refusal says what to fix rather | |
| 86 | /// than reading as the app's own mistake. | |
| 87 | @Test func aQueryTheServerCannotParseShowsItsMessage() async throws { | |
| 88 | let (model, stub) = try await loaded() | |
| 89 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"unknown qualifier; the qualifiers are repo:, owner:, is:, label:, no:, milestone:, assignee: and author:","exit_code":2}"#, match: "cmd")) | |
| 90 | ||
| 91 | #expect(!(await model.save(name: "x", query: "colour:red", replace: false))) | |
| 92 | #expect(model.actionError?.hasPrefix("unknown qualifier") == true) | |
| 93 | } | |
| 94 | ||
| 95 | @Test func pinUnpinAndRemoveNameTheQuery() async throws { | |
| 96 | let (model, stub) = try await loaded() | |
| 97 | for _ in 0..<3 { | |
| 98 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | |
| 99 | stub.enqueue(.init(status: 200, json: listJSON, match: "argv=list")) | |
| 100 | } | |
| 101 | let queries = try #require(model.state.value) | |
| 102 | ||
| 103 | await model.setPinned(queries[1], true) | |
| 104 | await model.setPinned(queries[0], false) | |
| 105 | await model.remove(queries[1]) | |
| 106 | ||
| 107 | #expect(try posted(stub) == [["query", "pin", "bugs"], ["query", "unpin", "mine"], ["query", "remove", "bugs"]]) | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | @MainActor | |
| 112 | struct QueryResultsTests { | |
| 113 | ||
| 114 | @Test func resultsPageThroughQueryRun() async throws { | |
| 115 | let (client, stub) = try makeClient() | |
| 116 | stub.enqueue(.init(status: 200, json: """ | |
| 117 | {"protocol_version":1,"data":{"items":[\ | |
| 118 | {"kind":"mr","repo":"krz/gitbay","number":12,"title":"Add it","state":"open","draft":true,\ | |
| 119 | "author":"cmc","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"},\ | |
| 120 | {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"closed",\ | |
| 121 | "author":"dee","milestone":"m1","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}],\ | |
| 122 | "next":"c1"},"exit_code":0} | |
| 123 | """)) | |
| 124 | let model = QueryResultsViewModel(client: client, name: "mine") | |
| 125 | ||
| 126 | await model.load() | |
| 127 | ||
| 128 | #expect(argv(try #require(stub.seen.first).url) == ["query", "run", "mine", "--limit", "50"]) | |
| 129 | let items = try #require(model.list.state.value) | |
| 130 | #expect(items.map(\.ref) == ["krz/gitbay!12", "krz/app#3"]) | |
| 131 | #expect(items.first?.draft == true) | |
| 132 | #expect(model.list.hasMore) | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | struct DashboardQueryTests { | |
| 137 | ||
| 138 | @Test func pinnedQueriesDecodeWithTheirRowsAndCount() throws { | |
| 139 | let json = """ | |
| 140 | {"queries":[{"name":"mine","query":"is:open author:@me","count":7,"items":[\ | |
| 141 | {"kind":"issue","repo":"krz/app","number":3,"title":"Bug","state":"open",\ | |
| 142 | "author":"dee","created_at":"2026-08-20T10:00:00.000Z","updated_at":"2026-08-21T10:00:00.000Z"}]},\ | |
| 143 | {"name":"broken","query":"label:","count":0,"items":[],"error":"label: needs a value"}]} | |
| 144 | """ | |
| 145 | let data = try GitbayClient.decoder().decode(DashboardData.self, from: Data(json.utf8)) | |
| 146 | #expect(data.queries.map(\.name) == ["mine", "broken"]) | |
| 147 | #expect(data.queries.first?.count == 7) | |
| 148 | #expect(data.queries.last?.error == "label: needs a value") | |
| 149 | // A server from before saved queries sends none. | |
| 150 | let old = try GitbayClient.decoder().decode(DashboardData.self, from: Data("{}".utf8)) | |
| 151 | #expect(old.queries.isEmpty) | |
| 152 | } | |
| 153 | } | |