From 67a233c76d9d6be3555a2944a80880c2b75d8062 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 2 Nov 2026 05:15:37 +0000 Subject: [PATCH] zbugs-swift: port issueListCount and show the list's count issueListCount reduces the list's filters (filterListQuery, now shared with buildListQuery as in TS) to a count(). The drift test has samples of it and no longer exempts it. The list's toolbar shows the count once the server has it, and the list's running total before, as the list page does. Co-authored-by: tmm Amp-Thread-ID: https://ampcode.com/threads/T-01a19a37-5aa9-7256-bff5-cdc47c01a1fe --- apps/zbugs-swift/Sources/ZBugs/Queries.swift | 32 +- .../Sources/ZBugsUI/IssueList.swift | 52 ++ .../Sources/ZBugsUI/RootView.swift | 5 + .../Tests/ZBugsTests/DriftTests.swift | 4 +- .../Tests/ZBugsTests/drift-vectors.json | 553 ++++++++++++++++++ apps/zbugs-swift/drift-samples.ts | 3 + 6 files changed, 642 insertions(+), 7 deletions(-) diff --git a/apps/zbugs-swift/Sources/ZBugs/Queries.swift b/apps/zbugs-swift/Sources/ZBugs/Queries.swift index 37c8aded38..2331570f4e 100644 --- a/apps/zbugs-swift/Sources/ZBugs/Queries.swift +++ b/apps/zbugs-swift/Sources/ZBugs/Queries.swift @@ -178,6 +178,13 @@ public struct IssueListV2Args: Codable, Sendable, Hashable { } } +public struct IssueListCountArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public init(listContext: ListContextParams) { + self.listContext = listContext + } +} + public struct ListIssueByIDArgs: Codable, Sendable, Hashable { public var listContext: ListContextParams public var idField: IDField @@ -375,6 +382,13 @@ public enum Queries { } /// One issue, for the order to start a list at. + /// The number of issues in the list: the same filters and permissions as + /// `issueListV2`, reduced to a `count()`. + public static let issueListCount = defineQuery("issueListCount") { + (args: IssueListCountArgs, auth: AuthData?) in + filterListQuery(zql.issue, args.listContext, auth?.role).count() + } + public static let listIssueByID = defineQuery("listIssueByID") { (args: ListIssueByIDArgs, auth: AuthData?) in buildListQuery(ListQueryArgs(listContext: args.listContext, role: auth?.role)) @@ -421,7 +435,8 @@ public enum Queries { /// Every query, for the server's registry and the drift test. public static let all: [any NamedQuery] = [ allLabels, allUsers, allProjects, user, crewUser, labels, labelsOrderByName, issuePreloadV2, - userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, listIssueByID, + userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, issueListCount, + listIssueByID, emojiChange, prevNext, issueList, userPicker, ] } @@ -483,8 +498,6 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { guard let listContext = args.listContext else { return alwaysFalse(q) } - let projectName = (listContext.projectName ?? zeroProjectName).lowercased() - q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } let dir: AST.Direction = args.dir == .forward @@ -502,6 +515,17 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { q = q.limit(limit) } + return filterListQuery(q, listContext, args.role) +} + +/// Narrows `q` to the issues `listContext` selects that `role` may see +/// (`filterListQuery`): shared by the list and its count. +func filterListQuery( + _ q: Query, _ listContext: ListContextParams, _ role: Role? +) -> Query { + let projectName = (listContext.projectName ?? zeroProjectName).lowercased() + var q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } + let text = truthy(listContext.textFilter).map { "%\(escapeLike($0))%" } q = q.where { issue in issue.and( @@ -532,7 +556,7 @@ func buildListQuery(_ args: ListQueryArgs) -> Query { if let assignee = truthy(listContext.assignee) { q = q.whereExists(\.assignee, scalar: true) { $0.where(\.login, assignee) } } - return applyIssuePermissions(q, args.role) + return applyIssuePermissions(q, role) } extension Query { diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index 3091ba58d5..9062341bdc 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -67,6 +67,58 @@ } } + /// The number of issues in the list, as zbugs' list page shows it: the + /// server's `issueListCount` once it has counted the list, in this session + /// or a previous one, and until then the list's running total. A text + /// search isn't counted: matching it checks every issue's comments, and it + /// changes with every keystroke. + struct IssueCount: View { + let filter: IssueFilter + let list: IssueVirtualizer? + + var body: some View { + if filter.text.isEmpty { + SyncedIssueCount(filter: filter, list: list) + } else { + IssueCountLabel(synced: nil, list: list) + } + } + } + + private struct SyncedIssueCount: View { + let list: IssueVirtualizer? + @ZeroQuery var count: Int + + init(filter: IssueFilter, list: IssueVirtualizer?) { + self.list = list + _count = ZeroQuery( + Queries.issueListCount(IssueListCountArgs(listContext: filter.listContext)), + ttl: .defaultTTL) + } + + var body: some View { + let counted = $count.resultType == .complete || $count.resultType == .cached + IssueCountLabel(synced: counted ? count : nil, list: list) + } + } + + private struct IssueCountLabel: View { + let synced: Int? + let list: IssueVirtualizer? + + var body: some View { + let s = list?.snapshot + let total = synced ?? s?.total + if let total { + Text(total.formatted()).accessibilityIdentifier("IssueCount") + } else if let s, s.complete || s.estimatedTotal > 0 { + // Rounded down to 50 above 50, as the list page does. + let e = s.estimatedTotal + Text("\((e < 50 ? e : e - e % 50).formatted())+").accessibilityIdentifier("IssueCount") + } + } + } + /// The issue list in SwiftUI. struct IssueList: View { let list: IssueVirtualizer diff --git a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift index 1c17fcaecc..d197e69942 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/RootView.swift @@ -194,6 +194,11 @@ } @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem { + IssueCount(filter: filter, list: issues) + .foregroundStyle(.secondary) + .monospacedDigit() + } ToolbarItem { ConnectionBadge(connection: model.zero.connection) } diff --git a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift index 3bf3593909..2eb5e99c84 100644 --- a/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift +++ b/apps/zbugs-swift/Tests/ZBugsTests/DriftTests.swift @@ -13,8 +13,6 @@ import ZeroTesting .appendingPathComponent("drift-vectors.json"), encoding: .utf8) let failures = try Drift.failures( vectors: vectors, schema: .zbugs, queries: Queries.all, mutators: Mutators.all) - // Not ported yet: issueListCount is a count(), and the Swift client has no - // aggregates. Exact, so this fails once it is ported and must be removed. - #expect(failures == ["query issueListCount: no Swift definition"]) + #expect(failures == []) } } diff --git a/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json b/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json index b3b5f38860..ae12ca31c2 100644 --- a/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json +++ b/apps/zbugs-swift/Tests/ZBugsTests/drift-vectors.json @@ -3289,6 +3289,559 @@ }, "hash": "1b5sw4o1vkbsje" }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": true, + "assignee": null, + "creator": null, + "labels": null, + "textFilter": null, + "sortField": "modified", + "sortDirection": "desc" + } + } + ], + "context": { + "sub": "u-alice", + "role": "user" + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": true + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "visibility" + }, + "right": { + "type": "literal", + "value": "public" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "1dr1ns00xbry3j" + }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": false, + "projectName": "Zero", + "assignee": "crew", + "creator": "alice", + "labels": [ + "bug", + "p1" + ], + "textFilter": "50%_off\\", + "sortField": "created", + "sortDirection": "asc" + } + } + ], + "context": { + "sub": "u-crew", + "role": "crew" + }, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": false + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "assigneeID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_assignee", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "login" + }, + "right": { + "type": "literal", + "value": "crew" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "zsubq_issueLabels", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "labelID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_label", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "name" + }, + "right": { + "type": "literal", + "value": "bug" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + }, + "system": "client" + }, + "op": "EXISTS" + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "issueLabel", + "alias": "zsubq_issueLabels", + "where": { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "labelID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "label", + "alias": "zsubq_label", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "name" + }, + "right": { + "type": "literal", + "value": "p1" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + }, + "system": "client" + }, + "op": "EXISTS" + }, + { + "type": "or", + "conditions": [ + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "description" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + }, + { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "title" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "id" + ], + "childField": [ + "issueID" + ] + }, + "subquery": { + "table": "comment", + "alias": "zsubq_comments", + "where": { + "type": "simple", + "op": "ILIKE", + "left": { + "type": "column", + "name": "body" + }, + "right": { + "type": "literal", + "value": "%50\\%\\_off\\%" + } + } + }, + "system": "client" + }, + "op": "EXISTS" + } + ] + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "creatorID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "user", + "alias": "zsubq_creator", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "login" + }, + "right": { + "type": "literal", + "value": "alice" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "0ta5n0m0bdljqv" + }, + { + "name": "issueListCount", + "args": [ + { + "listContext": { + "open": true, + "assignee": null, + "creator": null, + "labels": null, + "textFilter": null, + "sortField": "modified", + "sortDirection": "desc" + } + } + ], + "context": null, + "ast": { + "table": "issue", + "where": { + "type": "and", + "conditions": [ + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "open" + }, + "right": { + "type": "literal", + "value": true + } + }, + { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "visibility" + }, + "right": { + "type": "literal", + "value": "public" + } + }, + { + "type": "correlatedSubquery", + "related": { + "correlation": { + "parentField": [ + "projectID" + ], + "childField": [ + "id" + ] + }, + "subquery": { + "table": "project", + "alias": "zsubq_project", + "where": { + "type": "simple", + "op": "=", + "left": { + "type": "column", + "name": "lowerCaseName" + }, + "right": { + "type": "literal", + "value": "zero" + } + } + }, + "system": "client" + }, + "op": "EXISTS", + "scalar": true + } + ] + }, + "aggregate": { + "fn": "count" + } + }, + "hash": "1dr1ns00xbry3j" + }, { "name": "listIssueByID", "args": [ diff --git a/apps/zbugs-swift/drift-samples.ts b/apps/zbugs-swift/drift-samples.ts index 90a76913dd..87f3da2f2b 100644 --- a/apps/zbugs-swift/drift-samples.ts +++ b/apps/zbugs-swift/drift-samples.ts @@ -195,6 +195,9 @@ const querySamples: QuerySample[] = [ name: 'issueListV2', args: {listContext, limit: 0, start: rowSort, dir: 'forward'}, }, + {name: 'issueListCount', args: {listContext}, context: alice}, + {name: 'issueListCount', args: {listContext: fullListContext}, context: crew}, + {name: 'issueListCount', args: {listContext}}, { name: 'listIssueByID', args: {listContext, idField: 'shortID', idValue: 12},