Commit 6f9736ba2c
Verified · cmc
Layout: unified · split
gitbay/Issues/IssueListViewModel.swift +35 −11
| @@ -6,19 +6,23 @@ import Observation | |||
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class IssueListViewModel { | 7 | final class IssueListViewModel { |
| 8 | 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | ||
| 10 | case open, closed, all | ||
| 11 | var id: String { rawValue } | ||
| 12 | } | ||
| 13 | |||
| 14 | let list: PagedListModel<Issue> | 9 | let list: PagedListModel<Issue> |
| 15 | let repoPath: String | 10 | let repoPath: String |
| 16 | private let client: GitbayClient | 11 | private let client: GitbayClient |
| 17 | var filter: StateFilter = .open { | 12 | var filter = IssueFilter() { |
| 18 | didSet { | 13 | didSet { |
| 19 | guard filter != oldValue else { return } | 14 | guard filter != oldValue else { return } |
| 20 | configureList() | 15 | configureList() |
| 21 | Task { await list.reload() } | 16 | // Only the text changed: debounce, so typing does not fire a |
| 17 | // request per keystroke. Anything else is a picker: reload now. | ||
| 18 | var typed = oldValue | ||
| 19 | typed.search = filter.search | ||
| 20 | if typed == filter { | ||
| 21 | scheduleSearchReload() | ||
| 22 | } else { | ||
| 23 | reloadTask?.cancel() | ||
| 24 | reloadTask = Task { await list.reload() } | ||
| 25 | } | ||
| 22 | } | 26 | } |
| 23 | } | 27 | } |
| 24 | 28 | ||
| @@ -31,13 +35,17 @@ final class IssueListViewModel { | |||
| 31 | /// not on every reload. | 35 | /// not on every reload. |
| 32 | private var labelsLoaded = false | 36 | private var labelsLoaded = false |
| 33 | 37 | ||
| 38 | /// Open and closed milestones for the filter picker; fetched on first use. | ||
| 39 | private(set) var milestones: [Milestone] = [] | ||
| 40 | private var reloadTask: Task<Void, Never>? | ||
| 41 | |||
| 34 | init(client: GitbayClient, repoPath: String) { | 42 | init(client: GitbayClient, repoPath: String) { |
| 35 | self.client = client | 43 | self.client = client |
| 36 | self.repoPath = repoPath | 44 | self.repoPath = repoPath |
| 37 | list = PagedListModel( | 45 | list = PagedListModel( |
| 38 | client: client, | 46 | client: client, |
| 39 | argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue], | 47 | argv: ["issue", "list", repoPath] + IssueFilter().flags(), |
| 40 | emptyMessage: "No open issues." | 48 | emptyMessage: IssueFilter().emptyMessage |
| 41 | ) | 49 | ) |
| 42 | } | 50 | } |
| 43 | 51 | ||
| @@ -52,12 +60,28 @@ final class IssueListViewModel { | |||
| 52 | colors = LabelColors(labels) | 60 | colors = LabelColors(labels) |
| 53 | } | 61 | } |
| 54 | 62 | ||
| 63 | func loadMilestones() async { | ||
| 64 | guard milestones.isEmpty else { return } | ||
| 65 | milestones = (try? await client.readList( | ||
| 66 | ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self | ||
| 67 | )) ?? [] | ||
| 68 | } | ||
| 69 | |||
| 55 | private func labelList() async -> [IssueLabel] { | 70 | private func labelList() async -> [IssueLabel] { |
| 56 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | 71 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] |
| 57 | } | 72 | } |
| 58 | 73 | ||
| 74 | private func scheduleSearchReload() { | ||
| 75 | reloadTask?.cancel() | ||
| 76 | reloadTask = Task { | ||
| 77 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | ||
| 78 | guard !Task.isCancelled else { return } | ||
| 79 | await list.reload() | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 59 | private func configureList() { | 83 | private func configureList() { |
| 60 | list.argv = ["issue", "list", repoPath, "--state", filter.rawValue] | 84 | list.argv = ["issue", "list", repoPath] + filter.flags() |
| 61 | list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues." | 85 | list.emptyMessage = filter.emptyMessage |
| 62 | } | 86 | } |
| 63 | } | 87 | } |
gitbay/MRs/MRListViewModel.swift +37 −12
| @@ -6,27 +6,37 @@ import Observation | |||
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class MRListViewModel { | 7 | final class MRListViewModel { |
| 8 | 8 | ||
| 9 | enum StateFilter: String, CaseIterable, Identifiable, Sendable { | ||
| 10 | case open, merged, closed, all | ||
| 11 | var id: String { rawValue } | ||
| 12 | } | ||
| 13 | |||
| 14 | let list: PagedListModel<MergeRequest> | 9 | let list: PagedListModel<MergeRequest> |
| 15 | let repoPath: String | 10 | let repoPath: String |
| 16 | var filter: StateFilter = .open { | 11 | private let client: GitbayClient |
| 12 | var filter = MRFilter() { | ||
| 17 | didSet { | 13 | didSet { |
| 18 | guard filter != oldValue else { return } | 14 | guard filter != oldValue else { return } |
| 19 | configureList() | 15 | configureList() |
| 20 | Task { await list.reload() } | 16 | // Only the text changed: debounce, so typing does not fire a |
| 17 | // request per keystroke. Anything else is a picker: reload now. | ||
| 18 | var typed = oldValue | ||
| 19 | typed.search = filter.search | ||
| 20 | if typed == filter { | ||
| 21 | scheduleSearchReload() | ||
| 22 | } else { | ||
| 23 | reloadTask?.cancel() | ||
| 24 | reloadTask = Task { await list.reload() } | ||
| 25 | } | ||
| 21 | } | 26 | } |
| 22 | } | 27 | } |
| 23 | 28 | ||
| 29 | /// Open and closed milestones for the filter picker; fetched on first use. | ||
| 30 | private(set) var milestones: [Milestone] = [] | ||
| 31 | private var reloadTask: Task<Void, Never>? | ||
| 32 | |||
| 24 | init(client: GitbayClient, repoPath: String) { | 33 | init(client: GitbayClient, repoPath: String) { |
| 34 | self.client = client | ||
| 25 | self.repoPath = repoPath | 35 | self.repoPath = repoPath |
| 26 | list = PagedListModel( | 36 | list = PagedListModel( |
| 27 | client: client, | 37 | client: client, |
| 28 | argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue], | 38 | argv: ["mr", "list", repoPath] + MRFilter().flags(), |
| 29 | emptyMessage: "No open merge requests." | 39 | emptyMessage: MRFilter().emptyMessage |
| 30 | ) | 40 | ) |
| 31 | } | 41 | } |
| 32 | 42 | ||
| @@ -36,9 +46,24 @@ final class MRListViewModel { | |||
| 36 | await list.reload() | 46 | await list.reload() |
| 37 | } | 47 | } |
| 38 | 48 | ||
| 49 | func loadMilestones() async { | ||
| 50 | guard milestones.isEmpty else { return } | ||
| 51 | milestones = (try? await client.readList( | ||
| 52 | ["milestone", "list", repoPath, "--state", "all"], of: Milestone.self | ||
| 53 | )) ?? [] | ||
| 54 | } | ||
| 55 | |||
| 56 | private func scheduleSearchReload() { | ||
| 57 | reloadTask?.cancel() | ||
| 58 | reloadTask = Task { | ||
| 59 | guard (try? await Task.sleep(for: .milliseconds(300))) != nil else { return } | ||
| 60 | guard !Task.isCancelled else { return } | ||
| 61 | await list.reload() | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 39 | private func configureList() { | 65 | private func configureList() { |
| 40 | list.argv = ["mr", "list", repoPath, "--state", filter.rawValue] | 66 | list.argv = ["mr", "list", repoPath] + filter.flags() |
| 41 | list.emptyMessage = | 67 | list.emptyMessage = filter.emptyMessage |
| 42 | "No \(filter == .all ? "" : filter.rawValue + " ")merge requests." | ||
| 43 | } | 68 | } |
| 44 | } | 69 | } |
gitbay/Views/Issues/IssueListView.swift +3 −3
| @@ -15,9 +15,9 @@ struct IssueListView: View { | |||
| 15 | 15 | ||
| 16 | var body: some View { | 16 | var body: some View { |
| 17 | List { | 17 | List { |
| 18 | Picker("State", selection: Bindable(model).filter) { | 18 | Picker("State", selection: Bindable(model).filter.state) { |
| 19 | ForEach(IssueListViewModel.StateFilter.allCases) { filter in | 19 | ForEach(IssueFilter.State.allCases) { filter in |
| 20 | Text(filter.rawValue.capitalized).tag(filter) | 20 | Text(filter.label).tag(filter) |
| 21 | } | 21 | } |
| 22 | } | 22 | } |
| 23 | .pickerStyle(.segmented) | 23 | .pickerStyle(.segmented) |
gitbay/Views/MRs/MRListView.swift +3 −3
| @@ -13,9 +13,9 @@ struct MRListView: View { | |||
| 13 | 13 | ||
| 14 | var body: some View { | 14 | var body: some View { |
| 15 | List { | 15 | List { |
| 16 | Picker("State", selection: Bindable(model).filter) { | 16 | Picker("State", selection: Bindable(model).filter.state) { |
| 17 | ForEach(MRListViewModel.StateFilter.allCases) { filter in | 17 | ForEach(MRFilter.State.allCases) { filter in |
| 18 | Text(filter.rawValue.capitalized).tag(filter) | 18 | Text(filter.label).tag(filter) |
| 19 | } | 19 | } |
| 20 | } | 20 | } |
| 21 | .pickerStyle(.segmented) | 21 | .pickerStyle(.segmented) |
gitbayTests/ListFilterTests.swift +127
| @@ -129,3 +129,130 @@ struct MRFilterTests { | |||
| 129 | #expect(narrowed.emptyMessage == "No open merge requests match these filters.") | 129 | #expect(narrowed.emptyMessage == "No open merge requests match these filters.") |
| 130 | } | 130 | } |
| 131 | } | 131 | } |
| 132 | |||
| 133 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 134 | let box = StubProtocol.box() | ||
| 135 | let client = GitbayClient( | ||
| 136 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 137 | token: "test-token", | ||
| 138 | session: box.session() | ||
| 139 | ) | ||
| 140 | return (client, box) | ||
| 141 | } | ||
| 142 | |||
| 143 | private let emptyPage = """ | ||
| 144 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 145 | """ | ||
| 146 | private let noLabels = """ | ||
| 147 | {"protocol_version":1,"data":[],"exit_code":0} | ||
| 148 | """ | ||
| 149 | |||
| 150 | /// Every `argv=` value from a request URL, in order. | ||
| 151 | private func argvFrom(_ url: URL) -> [String] { | ||
| 152 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | ||
| 153 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | ||
| 154 | } | ||
| 155 | |||
| 156 | @MainActor | ||
| 157 | struct IssueListFilterWiringTests { | ||
| 158 | |||
| 159 | @Test func theDefaultReadIsOpenIssues() async throws { | ||
| 160 | let (client, stub) = try makeClient() | ||
| 161 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 162 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 163 | |||
| 164 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 165 | await model.load() | ||
| 166 | |||
| 167 | let read = try #require(stub.seen.first { $0.url.absoluteString.contains("argv=issue") }) | ||
| 168 | #expect(argvFrom(read.url).prefix(6) == [ | ||
| 169 | "issue", "list", "krz/gitbay", "--state", "open", "--limit", | ||
| 170 | ]) | ||
| 171 | } | ||
| 172 | |||
| 173 | @Test func aPickerChangeReloadsWithTheNewFlags() async throws { | ||
| 174 | let (client, stub) = try makeClient() | ||
| 175 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 176 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 177 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 178 | await model.load() | ||
| 179 | |||
| 180 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 181 | model.filter.label = "bug" | ||
| 182 | try await Task.sleep(for: .milliseconds(150)) | ||
| 183 | |||
| 184 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 185 | let last = try #require(reads.last) | ||
| 186 | #expect(argvFrom(last.url).contains("--label")) | ||
| 187 | #expect(argvFrom(last.url).contains("bug")) | ||
| 188 | // Immediate, not debounced: two reads by now. | ||
| 189 | #expect(reads.count == 2) | ||
| 190 | } | ||
| 191 | |||
| 192 | /// Typing must not fire a request per keystroke. | ||
| 193 | @Test func typingDebouncesIntoOneRead() async throws { | ||
| 194 | let (client, stub) = try makeClient() | ||
| 195 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 196 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 197 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 198 | await model.load() | ||
| 199 | |||
| 200 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 201 | for text in ["c", "cr", "cra", "cras", "crash"] { | ||
| 202 | model.filter.search = text | ||
| 203 | try await Task.sleep(for: .milliseconds(20)) | ||
| 204 | } | ||
| 205 | try await Task.sleep(for: .milliseconds(500)) | ||
| 206 | |||
| 207 | let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } | ||
| 208 | #expect(reads.count == 2) | ||
| 209 | #expect(argvFrom(try #require(reads.last).url).contains("crash")) | ||
| 210 | } | ||
| 211 | |||
| 212 | @Test func theEmptyMessageComesFromTheFilter() async throws { | ||
| 213 | let (client, stub) = try makeClient() | ||
| 214 | stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) | ||
| 215 | stub.enqueue(.init(status: 200, json: noLabels, match: "argv=label")) | ||
| 216 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 217 | await model.load() | ||
| 218 | |||
| 219 | guard case .empty(let message) = model.state else { | ||
| 220 | Testing.Issue.record("expected empty, got \(model.state)") | ||
| 221 | return | ||
| 222 | } | ||
| 223 | #expect(message == "No open issues.") | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | @MainActor | ||
| 228 | struct MRListFilterWiringTests { | ||
| 229 | |||
| 230 | @Test func searchReachesTheCommand() async throws { | ||
| 231 | let (client, stub) = try makeClient() | ||
| 232 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 233 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 234 | await model.load() | ||
| 235 | |||
| 236 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 237 | model.filter.search = "retarget" | ||
| 238 | try await Task.sleep(for: .milliseconds(500)) | ||
| 239 | |||
| 240 | let last = try #require(stub.seen.last) | ||
| 241 | let argv = argvFrom(last.url) | ||
| 242 | #expect(argv.contains("--search")) | ||
| 243 | #expect(argv.contains("retarget")) | ||
| 244 | } | ||
| 245 | |||
| 246 | @Test func sourceGoneReachesTheCommandInItsWireSpelling() async throws { | ||
| 247 | let (client, stub) = try makeClient() | ||
| 248 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 249 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 250 | await model.load() | ||
| 251 | |||
| 252 | stub.enqueue(.init(status: 200, json: emptyPage)) | ||
| 253 | model.filter.state = .sourceGone | ||
| 254 | try await Task.sleep(for: .milliseconds(150)) | ||
| 255 | |||
| 256 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) | ||
| 257 | } | ||
| 258 | } | ||
gitbayTests/MRViewModelTests.swift +1 −1
| @@ -171,7 +171,7 @@ struct MRListViewModelTests { | |||
| 171 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | 171 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| 172 | await model.load() | 172 | await model.load() |
| 173 | 173 | ||
| 174 | model.filter = .merged | 174 | model.filter.state = .merged |
| 175 | // The reload happens in a spawned task; give it a beat. | 175 | // The reload happens in a spawned task; give it a beat. |
| 176 | try await Task.sleep(for: .milliseconds(300)) | 176 | try await Task.sleep(for: .milliseconds(300)) |
| 177 | 177 | ||