chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app's issue and pull lists refresh and hold still as they load
ios/Knotless/Views/RepoView.swift+64−33
| @@ -506,6 +506,13 @@ private struct BranchPicker: View { | ||
} | ||
| ||
/// A list a page at a time, for those screens that are only that. | ||
ios/Knotless/Views/RepoView.swiftline 508This doc comment described | ||
/// A list's first page read again, over all that was read of it: the rest stay, | ||
/// so a list scrolled far down stays where it was, but for any the new page moved up. | ||
func rereading<Item: Identifiable>(_ first: [Item], over old: [Item]) -> [Item] { | ||
let new = Set(first.map(\.id)) | ||
return first + old.dropFirst(first.count).filter { !new.contains($0.id) } | ||
} | ||
| ||
struct PagedList<Item: Identifiable & Equatable & Sendable, Row: View>: View { | ||
struct Page: Sendable { | ||
let items: [Item] | ||
| @@ -535,18 +542,27 @@ struct PagedList<Item: Identifiable & Equatable & Sendable, Row: View>: View { | ||
} | ||
} | ||
.listStyle(.plain) | ||
.task { | ||
do { | ||
let page = try await fetch(nil) | ||
items = page.items | ||
cursor = page.cursor | ||
} catch is CancellationError { | ||
return | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
loading = false | ||
// Each time it's shown again, as a pull may have changed while it wasn't. | ||
.task { await read(keepingPages: true) } | ||
.refreshable { await read(keepingPages: false) } | ||
} | ||
| ||
private func read(keepingPages: Bool) async { | ||
do { | ||
let page = try await fetch(nil) | ||
let old = keepingPages ? items : [] | ||
cursor = old.count > page.items.count ? cursor : page.cursor | ||
items = rereading(page.items, over: old) | ||
failure = nil | ||
} catch is CancellationError { | ||
return | ||
} catch let error as URLError where error.code == .cancelled { | ||
return | ||
} catch { | ||
// What's on screen stays, rather than a list gone for a lost connection. | ||
if items.isEmpty { failure = error.localizedDescription } | ||
} | ||
loading = false | ||
} | ||
| ||
private func more() async { | ||
| @@ -680,62 +696,77 @@ struct ItemsView: View { | ||
let kind: String | ||
| ||
@State private var state = "open" | ||
@State private var answer = ItemList() | ||
@State private var loading = true | ||
/// What's been read of each state, so going back to one shows it at once. | ||
@State private var answers: [String: ItemList] = [:] | ||
@State private var failure: String? | ||
| ||
private var shown: ItemList? { answers[state] } | ||
/// The counts, which every answer has, while this state's is on its way. | ||
private var counts: ItemList? { shown ?? answers.values.first } | ||
| ||
var body: some View { | ||
List { | ||
Picker("State", selection: $state) { | ||
Text(label("Open", answer.openCount)).tag("open") | ||
Text(label("Closed", answer.closedCount)).tag("closed") | ||
Text(label("Open", counts?.openCount)).tag("open") | ||
Text(label("Closed", counts?.closedCount)).tag("closed") | ||
} | ||
.pickerStyle(.segmented) | ||
.listRowSeparator(.hidden) | ||
| ||
if let failure { | ||
if let shown { | ||
if shown.items.isEmpty { | ||
Text(state == "open" ? "Nothing open." : "Nothing closed.").foregroundStyle(.secondary) | ||
} | ||
ForEach(shown.items) { item in | ||
NavigationLink(value: Route.thread(repo, uri: item.uri)) { ItemRow(item: item) } | ||
.onAppear { if item == shown.items.last { Task { await more() } } } | ||
} | ||
} else if let failure { | ||
Text(failure).foregroundStyle(.secondary) | ||
} else if loading { | ||
} else { | ||
ProgressView().frame(maxWidth: .infinity) | ||
} else if answer.items.isEmpty { | ||
Text(state == "open" ? "Nothing open." : "Nothing closed.").foregroundStyle(.secondary) | ||
} | ||
ForEach(answer.items) { item in | ||
NavigationLink(value: Route.thread(repo, uri: item.uri)) { ItemRow(item: item) } | ||
.onAppear { if item == answer.items.last { Task { await more() } } } | ||
} | ||
} | ||
.listStyle(.plain) | ||
.navigationTitle(kind == "pull" ? "Pulls" : "Issues") | ||
.navigationBarTitleDisplayMode(.inline) | ||
.task(id: state) { await reload() } | ||
// Each time it's shown again too, as a pull may have changed while it wasn't. | ||
.task(id: state) { await read(keepingPages: true) } | ||
.refreshable { await read(keepingPages: false) } | ||
} | ||
| ||
private func label(_ name: String, _ count: Int?) -> String { | ||
count.map { "\(name) \($0)" } ?? name | ||
} | ||
| ||
private func fetch(_ cursor: String?) async throws -> ItemList { | ||
private func fetch(_ state: String, _ cursor: String?) async throws -> ItemList { | ||
try await Client.shared.query("listItems", ["repo": repo.uri, "kind": kind, "state": state, "limit": "30", "cursor": cursor]) | ||
} | ||
| ||
private func reload() async { | ||
loading = true | ||
private func read(keepingPages: Bool) async { | ||
let asked = state | ||
failure = nil | ||
do { | ||
answer = try await fetch(nil) | ||
var page = try await fetch(asked, nil) | ||
if keepingPages, let old = answers[asked], old.items.count > page.items.count { | ||
page.cursor = old.cursor | ||
page.items = rereading(page.items, over: old.items) | ||
} | ||
answers[asked] = page | ||
} catch is CancellationError { | ||
return | ||
} catch let error as URLError where error.code == .cancelled { | ||
} catch { | ||
// What's on screen stays, rather than a list gone for a lost connection. | ||
failure = error.localizedDescription | ||
} | ||
loading = false | ||
} | ||
| ||
private func more() async { | ||
guard let cursor = answer.cursor, let next = try? await fetch(cursor) else { return } | ||
answer.cursor = next.cursor | ||
answer.items += next.items.filter { !answer.items.contains($0) } | ||
let asked = state | ||
guard let cursor = answers[asked]?.cursor, let next = try? await fetch(asked, cursor) else { return } | ||
answers[asked]?.cursor = next.cursor | ||
let have = answers[asked]?.items ?? [] | ||
answers[asked]?.items = have + next.items.filter { !have.contains($0) } | ||
} | ||
} | ||