import Foundation import Observation /// One saved query (`query list`, gitbay v1.39.0): an issue and merge /// request query across repositories, kept under a name. nonisolated struct SavedQuery: Decodable, Sendable, Hashable, Identifiable { let name: String let query: String let pinned: Bool var id: String { name } } /// A row of `query run` and of a pinned query on the dashboard: an issue /// or merge request, naming its repository. nonisolated struct QueryItem: Decodable, Sendable, Hashable, Identifiable { let kind: String let repo: String let number: Int64 let title: String let state: String let draft: Bool? let author: String let milestone: String? let createdAt: Date let updatedAt: Date enum CodingKeys: String, CodingKey { case kind, repo, number, title, state, draft, author, milestone case createdAt = "created_at" case updatedAt = "updated_at" } var isMR: Bool { kind == "mr" } /// owner/name#n or owner/name!n, as a person writes it. var ref: String { repo + (isMR ? "!" : "#") + String(number) } var id: String { ref } var route: any Hashable { isMR ? MRRoute.mr(repo: repo, number: number) as any Hashable : IssueRoute.issue(repo: repo, number: number) } } /// A pinned query on the dashboard: its first rows and how many it /// matches in all. `error` is set when the saved text no longer parses. nonisolated struct DashboardQuery: Decodable, Sendable, Hashable, Identifiable { let name: String let query: String let count: Int let items: [QueryItem] let error: String? var id: String { name } } /// `query list`, and the writes on it: save, pin, unpin, remove. @Observable @MainActor final class SavedQueriesViewModel { private(set) var state: LoadState<[SavedQuery]> = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient init(client: GitbayClient) { self.client = client } func load() async { do { let queries = try await client.readList(["query", "list"], of: SavedQuery.self) state = queries.isEmpty ? .empty("No saved queries. Save one from the + button.") : .loaded(queries) } catch { state = .from(error) } } /// `query save [--force]`. The query goes as one word; /// the server reads it the same as many. Returns whether it saved. func save(name: String, query: String, replace: Bool) async -> Bool { // Flags, then "--": a query may start with dashes, and the server's // parser would read it as a flag. var argv = ["query", "save"] if replace { argv.append("--force") } argv += ["--", name.trimmingCharacters(in: .whitespaces), query.trimmingCharacters(in: .whitespacesAndNewlines)] return await perform(argv) } func setPinned(_ query: SavedQuery, _ pinned: Bool) async { await perform(["query", pinned ? "pin" : "unpin", query.name]) } func remove(_ query: SavedQuery) async { await perform(["query", "remove", query.name]) } /// A closed sheet takes its error with it. func clearError() { actionError = nil } @discardableResult private func perform(_ argv: [String]) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv) } catch let error as GitbayError { // The name and the query are typed, so a usage refusal is theirs. actionError = error.messageForTypedInput return false } catch { actionError = GitbayError.transport(error).userFacingMessage return false } await load() // The dashboard underneath shows pinned queries. NotificationCenter.default.post(name: .gitbaySavedQueriesChanged, object: nil) return true } } /// `query run `, paged. @Observable @MainActor final class QueryResultsViewModel { let list: PagedListModel let name: String init(client: GitbayClient, name: String) { self.name = name list = PagedListModel(client: client, argv: ["query", "run", name], emptyMessage: "Nothing matches.") } func load() async { await list.reload() } }