Commit fe2832564a

fe2832564a7c2963abe2c7ce86124c825f36cf76

parent: 563215175a

Verified · cmc

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

Saved queries: the dashboard follows; Edit; a query may start with dashes

A save, pin, unpin or removal reloads the dashboard underneath, which
showed the old pinned queries until pulled. Edit fills the sheet with a
saved query to replace, which is how one whose text no longer parses is
fixed. The query goes after "--", so one starting with dashes is not
read as a flag, and a closed sheet takes its error with it.

Ref #30

Layout: unified · split

gitbay/Discovery/SavedQuery.swift +12 −2
@@ -81,9 +81,12 @@ final class SavedQueriesViewModel {
81 /// `query save <name> <query> [--force]`. The query goes as one word; 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. 82 /// the server reads it the same as many. Returns whether it saved.
83 func save(name: String, query: String, replace: Bool) async -> Bool { 83 func save(name: String, query: String, replace: Bool) async -> Bool {
84 var argv = ["query", "save", name.trimmingCharacters(in: .whitespaces), 84 // Flags, then "--": a query may start with dashes, and the server's
85 query.trimmingCharacters(in: .whitespacesAndNewlines)] 85 // parser would read it as a flag.
86 var argv = ["query", "save"]
86 if replace { argv.append("--force") } 87 if replace { argv.append("--force") }
88 argv += ["--", name.trimmingCharacters(in: .whitespaces),
89 query.trimmingCharacters(in: .whitespacesAndNewlines)]
87 return await perform(argv) 90 return await perform(argv)
88 } 91 }
89 92
@@ -95,6 +98,11 @@ final class SavedQueriesViewModel {
95 await perform(["query", "remove", query.name]) 98 await perform(["query", "remove", query.name])
96 } 99 }
97 100
101 /// A closed sheet takes its error with it.
102 func clearError() {
103 actionError = nil
104 }
105
98 @discardableResult 106 @discardableResult
99 private func perform(_ argv: [String]) async -> Bool { 107 private func perform(_ argv: [String]) async -> Bool {
100 working = true 108 working = true
@@ -111,6 +119,8 @@ final class SavedQueriesViewModel {
111 return false 119 return false
112 } 120 }
113 await load() 121 await load()
122 // The dashboard underneath shows pinned queries.
123 NotificationCenter.default.post(name: .gitbaySavedQueriesChanged, object: nil)
114 return true 124 return true
115 } 125 }
116} 126}
gitbay/Views/Dashboard/DashboardView.swift +3
@@ -92,6 +92,9 @@ struct DashboardView: View {
92 } 92 }
93 .task { await model.load() } 93 .task { await model.load() }
94 .refreshable { await model.load() } 94 .refreshable { await model.load() }
95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
96 Task { await model.load() }
97 }
95 .onChange(of: model.state.value?.unread) { _, count in 98 .onChange(of: model.state.value?.unread) { _, count in
96 if let count { unread = count } 99 if let count { unread = count }
97 } 100 }
gitbay/Views/Discovery/SavedQueriesView.swift +24 −5
@@ -6,6 +6,8 @@ struct SavedQueriesView: View {
6 6
7 @State private var model: SavedQueriesViewModel 7 @State private var model: SavedQueriesViewModel
8 @State private var composing = false 8 @State private var composing = false
9 /// The query the sheet edits; nil saves a new one.
10 @State private var editing: SavedQuery?
9 @State private var removing: SavedQuery? 11 @State private var removing: SavedQuery?
10 12
11 init(client: GitbayClient) { 13 init(client: GitbayClient) {
@@ -49,6 +51,12 @@ struct SavedQueriesView: View {
49 .tint(Color.gbAccent) 51 .tint(Color.gbAccent)
50 } 52 }
51 .contextMenu { 53 .contextMenu {
54 Button {
55 editing = query
56 composing = true
57 } label: {
58 Label("Edit", systemImage: "pencil")
59 }
52 Button { 60 Button {
53 Task { await model.setPinned(query, !query.pinned) } 61 Task { await model.setPinned(query, !query.pinned) }
54 } label: { 62 } label: {
@@ -69,6 +77,7 @@ struct SavedQueriesView: View {
69 .toolbar { 77 .toolbar {
70 ToolbarItem(placement: .topBarTrailing) { 78 ToolbarItem(placement: .topBarTrailing) {
71 Button { 79 Button {
80 editing = nil
72 composing = true 81 composing = true
73 } label: { 82 } label: {
74 Image(systemName: "plus") 83 Image(systemName: "plus")
@@ -86,8 +95,8 @@ struct SavedQueriesView: View {
86 Button("Remove", role: .destructive) { Task { await model.remove(query) } } 95 Button("Remove", role: .destructive) { Task { await model.remove(query) } }
87 Button("Cancel", role: .cancel) {} 96 Button("Cancel", role: .cancel) {}
88 } 97 }
89 .sheet(isPresented: $composing) { 98 .sheet(isPresented: $composing, onDismiss: { model.clearError() }) {
90 SaveQuerySheet(model: model) { composing = false } 99 SaveQuerySheet(model: model, editing: editing) { composing = false }
91 } 100 }
92 .task { await model.load() } 101 .task { await model.load() }
93 .refreshable { await model.load() } 102 .refreshable { await model.load() }
@@ -99,12 +108,22 @@ private struct SaveQuerySheet: View {
99 let model: SavedQueriesViewModel 108 let model: SavedQueriesViewModel
100 let onSaved: () -> Void 109 let onSaved: () -> Void
101 110
102 @State private var name = "" 111 @State private var name: String
103 @State private var query = "" 112 @State private var query: String
104 @State private var replace = false 113 @State private var replace: Bool
105 @State private var sent = false 114 @State private var sent = false
106 @Environment(\.dismiss) private var dismiss 115 @Environment(\.dismiss) private var dismiss
107 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
108 var body: some View { 127 var body: some View {
109 NavigationStack { 128 NavigationStack {
110 Form { 129 Form {
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/SavedQueryTests.swift +2 −2
@@ -56,7 +56,7 @@ struct SavedQueriesViewModelTests {
56 let saved = await model.save(name: " review ", query: #" is:mr label:"needs review" "#, replace: false) 56 let saved = await model.save(name: " review ", query: #" is:mr label:"needs review" "#, replace: false)
57 57
58 #expect(saved) 58 #expect(saved)
59 #expect(try posted(stub) == [["query", "save", "review", #"is:mr label:"needs review""#]]) 59 #expect(try posted(stub) == [["query", "save", "--", "review", #"is:mr label:"needs review""#]])
60 } 60 }
61 61
62 @Test func replacingAddsForce() async throws { 62 @Test func replacingAddsForce() async throws {
@@ -66,7 +66,7 @@ struct SavedQueriesViewModelTests {
66 66
67 _ = await model.save(name: "bugs", query: "is:issue label:bug is:open", replace: true) 67 _ = await model.save(name: "bugs", query: "is:issue label:bug is:open", replace: true)
68 68
69 #expect(try posted(stub) == [["query", "save", "bugs", "is:issue label:bug is:open", "--force"]]) 69 #expect(try posted(stub) == [["query", "save", "--force", "--", "bugs", "is:issue label:bug is:open"]])
70 } 70 }
71 71
72 @Test func aRefusedSaveKeepsTheSheetAndSaysWhy() async throws { 72 @Test func aRefusedSaveKeepsTheSheetAndSaysWhy() async throws {