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

merged merged by cmc on 2026-10-14 07:30 UTC · krz/gitbay-ios:saved-queries into main

11 files changed, +637 −5

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 +144
@@ -0,0 +1,144 @@
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 // Flags, then "--": a query may start with dashes, and the server's
85 // parser would read it as a flag.
86 var argv = ["query", "save"]
87 if replace { argv.append("--force") }
88 argv += ["--", name.trimmingCharacters(in: .whitespaces),
89 query.trimmingCharacters(in: .whitespacesAndNewlines)]
90 return await perform(argv)
91 }
92
93 func setPinned(_ query: SavedQuery, _ pinned: Bool) async {
94 await perform(["query", pinned ? "pin" : "unpin", query.name])
95 }
96
97 func remove(_ query: SavedQuery) async {
98 await perform(["query", "remove", query.name])
99 }
100
101 /// A closed sheet takes its error with it.
102 func clearError() {
103 actionError = nil
104 }
105
106 @discardableResult
107 private func perform(_ argv: [String]) async -> Bool {
108 working = true
109 actionError = nil
110 defer { working = false }
111 do {
112 try await client.run(argv)
113 } catch let error as GitbayError {
114 // The name and the query are typed, so a usage refusal is theirs.
115 actionError = error.messageForTypedInput
116 return false
117 } catch {
118 actionError = GitbayError.transport(error).userFacingMessage
119 return false
120 }
121 await load()
122 // The dashboard underneath shows pinned queries.
123 NotificationCenter.default.post(name: .gitbaySavedQueriesChanged, object: nil)
124 return true
125 }
126}
127
128/// `query run <name>`, paged.
129@Observable
130@MainActor
131final class QueryResultsViewModel {
132
133 let list: PagedListModel<QueryItem>
134 let name: String
135
136 init(client: GitbayClient, name: String) {
137 self.name = name
138 list = PagedListModel(client: client, argv: ["query", "run", name], emptyMessage: "Nothing matches.")
139 }
140
141 func load() async {
142 await list.reload()
143 }
144}
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 +50
@@ -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) {
@@ -81,6 +92,9 @@ struct DashboardView: View {
81 } 92 }
82 .task { await model.load() } 93 .task { await model.load() }
83 .refreshable { await model.load() } 94 .refreshable { await model.load() }
95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
96 Task { await model.load() }
97 }
84 .onChange(of: model.state.value?.unread) { _, count in 98 .onChange(of: model.state.value?.unread) { _, count in
85 if let count { unread = count } 99 if let count { unread = count }
86 } 100 }
@@ -140,6 +154,42 @@ struct DashboardView: View {
140 .padding(.vertical, 2) 154 .padding(.vertical, 2)
141 } 155 }
142 156
157 /// A pinned saved query: its first rows, and the rest behind its name,
158 /// as the web dashboard shows it.
159 private func querySection(_ query: DashboardQuery) -> some View {
160 Section {
161 if let error = query.error {
162 Text(error)
163 .font(.gbSans(.subheadline))
164 .foregroundStyle(.secondary)
165 } else if query.items.isEmpty {
166 Text("Nothing matches")
167 .font(.gbSans(.subheadline))
168 .foregroundStyle(.secondary)
169 } else {
170 ForEach(query.items) { item in
171 NavigationLink(value: item.route) {
172 QueryItemRow(item: item)
173 }
174 }
175 if query.count > query.items.count {
176 NavigationLink(value: RepoRoute.queryResults(name: query.name)) {
177 Text("All \(query.count)")
178 .font(.gbSans(.subheadline))
179 .foregroundStyle(Color.gbAccent)
180 }
181 }
182 }
183 } header: {
184 VStack(alignment: .leading, spacing: 2) {
185 sectionHeader(query.name, count: query.count)
186 Text(query.query)
187 .font(.gbMono(.caption2))
188 .textCase(nil)
189 }
190 }
191 }
192
143 private func pinnedSection(_ pinned: [RepoSummary]) -> some View { 193 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
144 Section { 194 Section {
145 if pinned.isEmpty { 195 if pinned.isEmpty {
gitbay/Views/Discovery/SavedQueriesView.swift added +255
@@ -0,0 +1,255 @@
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 /// The query the sheet edits; nil saves a new one.
10 @State private var editing: SavedQuery?
11 @State private var removing: SavedQuery?
12
13 init(client: GitbayClient) {
14 _model = State(initialValue: SavedQueriesViewModel(client: client))
15 }
16
17 var body: some View {
18 List {
19 if let error = model.actionError, !composing {
20 Section { GBNotice(error, .gbWarn) }
21 }
22 ForEach(model.state.value ?? []) { query in
23 NavigationLink(value: RepoRoute.queryResults(name: query.name)) {
24 VStack(alignment: .leading, spacing: 3) {
25 HStack(spacing: 6) {
26 Text(query.name)
27 .font(.gbSans(.subheadline).weight(.semibold))
28 if query.pinned {
29 GBChip("pinned", .secondary)
30 }
31 }
32 Text(query.query)
33 .font(.gbMono(.caption))
34 .foregroundStyle(.secondary)
35 .lineLimit(2)
36 }
37 .accessibilityElement(children: .combine)
38 }
39 .accessibilityIdentifier("saved-query-\(query.name)")
40 .swipeActions(edge: .trailing) {
41 Button(role: .destructive) {
42 removing = query
43 } label: {
44 Label("Remove", systemImage: "trash")
45 }
46 Button {
47 Task { await model.setPinned(query, !query.pinned) }
48 } label: {
49 Label(query.pinned ? "Unpin" : "Pin", systemImage: query.pinned ? "pin.slash" : "pin")
50 }
51 .tint(Color.gbAccent)
52 }
53 .contextMenu {
54 Button {
55 editing = query
56 composing = true
57 } label: {
58 Label("Edit", systemImage: "pencil")
59 }
60 Button {
61 Task { await model.setPinned(query, !query.pinned) }
62 } label: {
63 Label(query.pinned ? "Unpin from Dashboard" : "Pin to Dashboard",
64 systemImage: query.pinned ? "pin.slash" : "pin")
65 }
66 Button(role: .destructive) {
67 removing = query
68 } label: {
69 Label("Remove", systemImage: "trash")
70 }
71 }
72 }
73 }
74 .overlay { LoadStateOverlay(state: model.state) }
75 .navigationTitle("Saved Queries")
76 .navigationBarTitleDisplayMode(.inline)
77 .toolbar {
78 ToolbarItem(placement: .topBarTrailing) {
79 Button {
80 editing = nil
81 composing = true
82 } label: {
83 Image(systemName: "plus")
84 }
85 .accessibilityLabel("Save a Query")
86 .accessibilityIdentifier("saved-query-create")
87 }
88 }
89 .confirmationDialog(
90 removing.map { "Remove \($0.name)?" } ?? "",
91 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
92 titleVisibility: .visible,
93 presenting: removing
94 ) { query in
95 Button("Remove", role: .destructive) { Task { await model.remove(query) } }
96 Button("Cancel", role: .cancel) {}
97 }
98 .sheet(isPresented: $composing, onDismiss: { model.clearError() }) {
99 SaveQuerySheet(model: model, editing: editing) { composing = false }
100 }
101 .task { await model.load() }
102 .refreshable { await model.load() }
103 }
104}
105
106private struct SaveQuerySheet: View {
107
108 let model: SavedQueriesViewModel
109 let onSaved: () -> Void
110
111 @State private var name: String
112 @State private var query: String
113 @State private var replace: Bool
114 @State private var sent = false
115 @Environment(\.dismiss) private var dismiss
116
117 /// `editing` fills the sheet with a saved query to change, replacing it
118 /// under the same name: how one whose text no longer parses is fixed.
119 init(model: SavedQueriesViewModel, editing: SavedQuery?, onSaved: @escaping () -> Void) {
120 self.model = model
121 self.onSaved = onSaved
122 _name = State(initialValue: editing?.name ?? "")
123 _query = State(initialValue: editing?.query ?? "")
124 _replace = State(initialValue: editing != nil)
125 }
126
127 var body: some View {
128 NavigationStack {
129 Form {
130 Section("Name") {
131 TextField("mine", text: $name)
132 .font(.gbMono(.body))
133 .autocorrectionDisabled()
134 .textInputAutocapitalization(.never)
135 .accessibilityIdentifier("saved-query-name")
136 }
137 Section {
138 TextField("is:open author:@me", text: $query, axis: .vertical)
139 .font(.gbMono(.body))
140 .lineLimit(1...4)
141 .autocorrectionDisabled()
142 .textInputAutocapitalization(.never)
143 .accessibilityIdentifier("saved-query-text")
144 Toggle("Replace one with this name", isOn: $replace)
145 .font(.gbSans(.subheadline))
146 } header: {
147 Text("Query")
148 } footer: {
149 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.")
150 }
151 if sent, let error = model.actionError {
152 Section { GBNotice(error) }
153 }
154 }
155 .navigationTitle("Save a Query")
156 .navigationBarTitleDisplayMode(.inline)
157 .toolbar {
158 ToolbarItem(placement: .cancellationAction) {
159 Button("Cancel") { dismiss() }
160 }
161 ToolbarItem(placement: .confirmationAction) {
162 if model.working {
163 ProgressView()
164 } else {
165 Button("Save") {
166 sent = true
167 Task {
168 if await model.save(name: name, query: query, replace: replace) { onSaved() }
169 }
170 }
171 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
172 || query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
173 .accessibilityIdentifier("saved-query-save")
174 }
175 }
176 }
177 .interactiveDismissDisabled(model.working)
178 }
179 }
180}
181
182/// What a saved query matches, newest first, as `query run` pages it.
183struct QueryResultsView: View {
184
185 @State private var model: QueryResultsViewModel
186
187 init(client: GitbayClient, name: String) {
188 _model = State(initialValue: QueryResultsViewModel(client: client, name: name))
189 }
190
191 var body: some View {
192 List {
193 ForEach(model.list.state.value ?? []) { item in
194 NavigationLink(value: item.route) {
195 QueryItemRow(item: item, showsState: true)
196 }
197 .onAppear {
198 if item.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } }
199 }
200 }
201 PageFooter(list: model.list)
202 }
203 .overlay { LoadStateOverlay(state: model.list.state) }
204 .navigationTitle(model.name)
205 .navigationBarTitleDisplayMode(.inline)
206 .task { if model.list.needsLoad { await model.load() } }
207 .refreshable { await model.load() }
208 }
209}
210
211/// One issue or merge request a query matched.
212struct QueryItemRow: View {
213
214 let item: QueryItem
215 var showsState = false
216
217 var body: some View {
218 VStack(alignment: .leading, spacing: 3) {
219 HStack(alignment: .firstTextBaseline, spacing: 6) {
220 Text(item.title)
221 .font(.gbSans(.subheadline).weight(.semibold))
222 .lineLimit(2)
223 if showsState {
224 Spacer(minLength: 4)
225 GBChip(item.draft == true ? "draft" : item.state, stateColor)
226 }
227 }
228 HStack(spacing: 4) {
229 Text(item.ref)
230 Text("·")
231 Text(item.author)
232 .foregroundStyle(Color.gbAccent)
233 if let milestone = item.milestone {
234 Text("·")
235 Text("in \(milestone)")
236 }
237 Text("·")
238 Text(item.updatedAt, format: .relative(presentation: .named))
239 }
240 .font(.gbSans(.caption))
241 .foregroundStyle(.secondary)
242 .lineLimit(1)
243 }
244 .padding(.vertical, 2)
245 }
246
247 private var stateColor: Color {
248 if item.draft == true { return .secondary }
249 switch item.state {
250 case "open": return .gbOK
251 case "merged": return .gbDone
252 default: return .gbBad
253 }
254 }
255}
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)
gitbay/Views/Shared/ChangeNotices.swift added +11
@@ -0,0 +1,11 @@
1import Foundation
2
3/// Writes on one screen that change what another, already on the stack
4/// underneath, is showing. The one-shot `.task` loads of those screens do
5/// not run again on a pop, so they listen for these instead.
6extension Notification.Name {
7 /// A repository was deleted, moved or renamed; `object` is its old path.
8 static let gitbayRepositoryChanged = Notification.Name("gitbayRepositoryChanged")
9 /// A saved query was saved, pinned, unpinned or removed.
10 static let gitbaySavedQueriesChanged = Notification.Name("gitbaySavedQueriesChanged")
11}
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", "--force", "--", "bugs", "is:issue label:bug is:open"]])
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}