Saved queries: save, pin, results, and pinned on the dashboard !165
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 @@ | |||
| 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 | // 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 | ||
| 131 | final 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 @@ | |||
| 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 | /// 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 | |||
| 106 | private 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. | ||
| 183 | struct 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. | ||
| 212 | struct 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 @@ | |||
| 1 | import 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. | ||
| 6 | extension 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 @@ | |||
| 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", "--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 | ||
| 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 | } | ||