Commit 563215175a

563215175a0d312ee6c79313cd7e8aaf139baf3b

parent: fa2c0ba0bb

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:02 UTC

Saved queries: save, pin, results, and pinned on the dashboard

Saved Queries, from the dashboard's toolbar, lists your queries; one
opens its results, paged through query run, and a swipe pins it to the
dashboard or removes it. The + button saves a query, replacing one of
the same name when asked. Pinned queries show on the dashboard with
their first rows and count, as on the web.

A query, its name and a required check's name are typed, so a usage
refusal of one now shows the server's message instead of the app's
generic one.

Closes #30

Layout: unified · split

gitbay/ContentView.swift +4
@@ -319,6 +319,10 @@ private struct RouteDestinations: ViewModifier {
319 LabelListView(client: client, repo: repo) 319 LabelListView(client: client, repo: repo)
320 case .bookmarks: 320 case .bookmarks:
321 BookmarkListView(client: client) 321 BookmarkListView(client: client)
322 case .savedQueries:
323 SavedQueriesView(client: client)
324 case .queryResults(let name):
325 QueryResultsView(client: client, name: name)
322 case .webhooks(let repo): 326 case .webhooks(let repo):
323 WebhookListView(client: client, repo: repo) 327 WebhookListView(client: client, repo: repo)
324 case .webhookDeliveries(let repo): 328 case .webhookDeliveries(let repo):
gitbay/Dashboard/DashboardModels.swift +4 −1
@@ -17,9 +17,11 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable {
17 /// The unread notification count, so a client showing a badge does 17 /// The unread notification count, so a client showing a badge does
18 /// not need a second read to fill it. 18 /// not need a second read to fill it.
19 let unread: Int 19 let unread: Int
20 /// Pinned saved queries with their first rows (gitbay v1.39.0).
21 let queries: [DashboardQuery]
20 22
21 enum CodingKeys: String, CodingKey { 23 enum CodingKeys: String, CodingKey {
22 case pinned, builds, unread 24 case pinned, builds, unread, queries
23 case reviewQueue = "review_queue" 25 case reviewQueue = "review_queue"
24 case openMRs = "open_mrs" 26 case openMRs = "open_mrs"
25 case assignedIssues = "assigned_issues" 27 case assignedIssues = "assigned_issues"
@@ -37,6 +39,7 @@ nonisolated struct DashboardData: Decodable, Sendable, Hashable {
37 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? [] 39 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? []
38 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? [] 40 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? []
39 unread = try values.decodeIfPresent(Int.self, forKey: .unread) ?? 0 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 @@
1import Foundation
2import 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.
6nonisolated 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.
15nonisolated 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.
46nonisolated 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
58final 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
121final 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 var errorDescription: String? { userFacingMessage } 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 /// True when the right response is an empty screen rather than a banner. 66 /// True when the right response is an empty screen rather than a banner.
59 var isEmptyState: Bool { 67 var isEmptyState: Bool {
60 if case .notFound = self { return true } 68 if case .notFound = self { return true }
gitbay/Repos/RepoSettingsViewModel.swift +4 −3
@@ -130,8 +130,9 @@ final class RepoSettingsViewModel {
130 await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context }) 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 private func setRequiredContexts(_ contexts: [String]) async { 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 func setRequireSigned(_ on: Bool) async { 138 func setRequireSigned(_ on: Bool) async {
@@ -226,7 +227,7 @@ final class RepoSettingsViewModel {
226 await perform(argv, reload: load) 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 working = true 231 working = true
231 actionError = nil 232 actionError = nil
232 defer { working = false } 233 defer { working = false }
@@ -234,7 +235,7 @@ final class RepoSettingsViewModel {
234 try await client.run(argv) 235 try await client.run(argv)
235 await reload() 236 await reload()
236 } catch let error as GitbayError { 237 } catch let error as GitbayError {
237 actionError = error.userFacingMessage 238 actionError = typed ? error.messageForTypedInput : error.userFacingMessage
238 } catch { 239 } catch {
239 actionError = GitbayError.transport(error).userFacingMessage 240 actionError = GitbayError.transport(error).userFacingMessage
240 } 241 }
gitbay/Views/Dashboard/DashboardView.swift +47
@@ -40,6 +40,10 @@ struct DashboardView: View {
40 empty: "No open issues", marker: "#" 40 empty: "No open issues", marker: "#"
41 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) } 41 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
42 42
43 ForEach(data.queries) { query in
44 querySection(query)
45 }
46
43 pinnedSection(data.pinned) 47 pinnedSection(data.pinned)
44 activitySection(data.recentActivity) 48 activitySection(data.recentActivity)
45 } 49 }
@@ -47,6 +51,13 @@ struct DashboardView: View {
47 .overlay { LoadStateOverlay(state: model.state) } 51 .overlay { LoadStateOverlay(state: model.state) }
48 .navigationTitle("Dashboard") 52 .navigationTitle("Dashboard")
49 .toolbar { 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 ToolbarItem(placement: .topBarTrailing) { 61 ToolbarItem(placement: .topBarTrailing) {
51 RouteLink(value: RepoRoute.notifications) { 62 RouteLink(value: RepoRoute.notifications) {
52 HStack(spacing: 2) { 63 HStack(spacing: 2) {
@@ -140,6 +151,42 @@ struct DashboardView: View {
140 .padding(.vertical, 2) 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 private func pinnedSection(_ pinned: [RepoSummary]) -> some View { 190 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
144 Section { 191 Section {
145 if pinned.isEmpty { 192 if pinned.isEmpty {
gitbay/Views/Discovery/SavedQueriesView.swift added +236
@@ -0,0 +1,236 @@
1import 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.
5struct 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
97private 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.
164struct 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.
193struct 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 case wikiPage(repo: String, page: String) 21 case wikiPage(repo: String, page: String)
22 case commit(repo: String, sha: String) 22 case commit(repo: String, sha: String)
23 case bookmarks 23 case bookmarks
24 case savedQueries
25 case queryResults(name: String)
24 case webhooks(repo: String) 26 case webhooks(repo: String)
25 case webhookDeliveries(repo: String) 27 case webhookDeliveries(repo: String)
26 case profile(String) 28 case profile(String)
gitbayTests/RequiredContextsTests.swift +2 −1
@@ -74,7 +74,8 @@ struct RequiredContextsTests {
74 let cleared = await model.addRequiredContext("ci/more") 74 let cleared = await model.addRequiredContext("ci/more")
75 75
76 #expect(!cleared) 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 @Test func removingTheLastContextSendsAnEmptyList() async throws { 81 @Test func removingTheLastContextSendsAnEmptyList() async throws {
gitbayTests/SavedQueryTests.swift added +153
@@ -0,0 +1,153 @@
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 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
22private func argv(_ url: URL) -> [String] {
23 URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.compactMap(\.value) ?? []
24}
25
26private 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
31private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
32
33@MainActor
34struct 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
112struct 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
136struct 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}