import SwiftUI /// Your saved queries: open one for its results, swipe to pin it to the /// dashboard or remove it, and save a new one from the + button. struct SavedQueriesView: View { @State private var model: SavedQueriesViewModel @State private var composing = false /// The query the sheet edits; nil saves a new one. @State private var editing: SavedQuery? @State private var removing: SavedQuery? init(client: GitbayClient) { _model = State(initialValue: SavedQueriesViewModel(client: client)) } var body: some View { List { if let error = model.actionError, !composing { Section { GBNotice(error, .gbWarn) } } ForEach(model.state.value ?? []) { query in NavigationLink(value: RepoRoute.queryResults(name: query.name)) { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(query.name) .font(.gbSans(.subheadline).weight(.semibold)) if query.pinned { GBChip("pinned", .secondary) } } Text(query.query) .font(.gbMono(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } .accessibilityElement(children: .combine) } .accessibilityIdentifier("saved-query-\(query.name)") .swipeActions(edge: .trailing) { Button(role: .destructive) { removing = query } label: { Label("Remove", systemImage: "trash") } Button { Task { await model.setPinned(query, !query.pinned) } } label: { Label(query.pinned ? "Unpin" : "Pin", systemImage: query.pinned ? "pin.slash" : "pin") } .tint(Color.gbAccent) } .contextMenu { Button { editing = query composing = true } label: { Label("Edit", systemImage: "pencil") } Button { Task { await model.setPinned(query, !query.pinned) } } label: { Label(query.pinned ? "Unpin from Dashboard" : "Pin to Dashboard", systemImage: query.pinned ? "pin.slash" : "pin") } Button(role: .destructive) { removing = query } label: { Label("Remove", systemImage: "trash") } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Saved Queries") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { editing = nil composing = true } label: { Image(systemName: "plus") } .accessibilityLabel("Save a Query") .accessibilityIdentifier("saved-query-create") } } .confirmationDialog( removing.map { "Remove \($0.name)?" } ?? "", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible, presenting: removing ) { query in Button("Remove", role: .destructive) { Task { await model.remove(query) } } Button("Cancel", role: .cancel) {} } .sheet(isPresented: $composing, onDismiss: { model.clearError() }) { SaveQuerySheet(model: model, editing: editing) { composing = false } } .task { await model.load() } .refreshable { await model.load() } } } private struct SaveQuerySheet: View { let model: SavedQueriesViewModel let onSaved: () -> Void @State private var name: String @State private var query: String @State private var replace: Bool @State private var sent = false @Environment(\.dismiss) private var dismiss /// `editing` fills the sheet with a saved query to change, replacing it /// under the same name: how one whose text no longer parses is fixed. init(model: SavedQueriesViewModel, editing: SavedQuery?, onSaved: @escaping () -> Void) { self.model = model self.onSaved = onSaved _name = State(initialValue: editing?.name ?? "") _query = State(initialValue: editing?.query ?? "") _replace = State(initialValue: editing != nil) } var body: some View { NavigationStack { Form { Section("Name") { TextField("mine", text: $name) .font(.gbMono(.body)) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("saved-query-name") } Section { TextField("is:open author:@me", text: $query, axis: .vertical) .font(.gbMono(.body)) .lineLimit(1...4) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("saved-query-text") Toggle("Replace one with this name", isOn: $replace) .font(.gbSans(.subheadline)) } header: { Text("Query") } footer: { 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.") } if sent, let error = model.actionError { Section { GBNotice(error) } } } .navigationTitle("Save a Query") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Save") { sent = true Task { if await model.save(name: name, query: query, replace: replace) { onSaved() } } } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityIdentifier("saved-query-save") } } } .interactiveDismissDisabled(model.working) } } } /// What a saved query matches, newest first, as `query run` pages it. struct QueryResultsView: View { @State private var model: QueryResultsViewModel init(client: GitbayClient, name: String) { _model = State(initialValue: QueryResultsViewModel(client: client, name: name)) } var body: some View { List { ForEach(model.list.state.value ?? []) { item in NavigationLink(value: item.route) { QueryItemRow(item: item, showsState: true) } .onAppear { if item.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } } } } PageFooter(list: model.list) } .overlay { LoadStateOverlay(state: model.list.state) } .navigationTitle(model.name) .navigationBarTitleDisplayMode(.inline) .task { if model.list.needsLoad { await model.load() } } .refreshable { await model.load() } } } /// One issue or merge request a query matched. struct QueryItemRow: View { let item: QueryItem var showsState = false var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(item.title) .font(.gbSans(.subheadline).weight(.semibold)) .lineLimit(2) if showsState { Spacer(minLength: 4) GBChip(item.draft == true ? "draft" : item.state, stateColor) } } HStack(spacing: 4) { Text(item.ref) Text("·") Text(item.author) .foregroundStyle(Color.gbAccent) if let milestone = item.milestone { Text("·") Text("in \(milestone)") } Text("·") Text(item.updatedAt, format: .relative(presentation: .named)) } .font(.gbSans(.caption)) .foregroundStyle(.secondary) .lineLimit(1) } .padding(.vertical, 2) } private var stateColor: Color { if item.draft == true { return .secondary } switch item.state { case "open": return .gbOK case "merged": return .gbDone default: return .gbBad } } }