An audit for v0.1.0 turned up the same pattern in four places: a computed property doing real work (filtering/sorting/scoring a collection), read multiple times per render including from unrelated state changes (selection, hover, scroll), so the work reran far more often than the underlying data actually changed. Converted each to a @State cache recomputed only via onChange of its real inputs: - DocumentSearchSheet: matchingLineIndices re-scanned the whole document per access, read once per visible row plus twice more in the header/step logic - O(n^2) case-insensitive scan per frame on a large document. Also split into an ordered array (for currentMatchIndex/stepping) plus a parallel Set for the per-row highlight check, which was an O(k) linear .contains before. - CollectionDocumentsOutline: tree rebuilt the whole dictionary- grouped, recursively-sorted document tree on every body evaluation, not just when documents/sortOption actually changed. - CommandPaletteView: results re-scored and re-sorted the entire index (up to the whole local workspace cache in Full Workspace mode) on every render, including ones from selectedIndex moving as arrow keys are pressed. - CollectionOverviewView: sortedDocuments re-sorted on every render; same pattern, smaller blast radius (capped at 100 docs). Also: - HomeViewModel.fetchPinnedThrowing fetched each pinned document serially in a for loop (one round trip at a time) - switched to a TaskGroup so latency doesn't scale with pin count, results reordered back to pins.list's own order since task completion order isn't submission order. - AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG compression synchronously on the main actor from the "Use Photo" button tap. ImageRenderer itself has to stay on the main actor (it captures live SwiftUI state), but JPEG compression on the already- rendered bitmap has no SwiftUI dependency left - hopped that part to a detached Task via tiffRepresentation (plain Data, unlike NSImage itself isn't Sendable) so it doesn't hitch the UI. No crash risks or retain cycles found in the same audit (no try!/ as!, force-unwraps essentially absent outside a hardcoded URL literal, weak self already used where it matters) - this is purely the perf half of the findings. Not compiler-verified - Outpost app target has no CLI build path.
158 lines
6.5 KiB
Swift
158 lines
6.5 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OutlineKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class HomeViewModel {
|
|
private(set) var pinnedDocuments: [OutlineDocument] = []
|
|
private(set) var recentlyViewed: [OutlineDocument] = []
|
|
private(set) var popular: [OutlineDocument] = []
|
|
private(set) var recentlyUpdated: [OutlineDocument] = []
|
|
private(set) var createdByMe: [OutlineDocument] = []
|
|
private(set) var drafts: [OutlineDocument] = []
|
|
|
|
var isLoadingPinned = false
|
|
var isLoadingTab = false
|
|
var errorMessage: String?
|
|
/// Drives a "Refresh" banner rather than silently swapping content out
|
|
/// from under whoever's looking at it — see `CollectionsViewModel`'s
|
|
/// identical pattern.
|
|
var hasRemoteChanges = false
|
|
|
|
private let apiClient: OutlineAPIClient
|
|
private var currentUserID: String?
|
|
|
|
init(apiClient: OutlineAPIClient) {
|
|
self.apiClient = apiClient
|
|
}
|
|
|
|
func documents(for tab: HomeTab) -> [OutlineDocument] {
|
|
switch tab {
|
|
case .recentlyViewed: recentlyViewed
|
|
case .popular: popular
|
|
case .recentlyUpdated: recentlyUpdated
|
|
case .createdByMe: createdByMe
|
|
case .drafts: drafts
|
|
}
|
|
}
|
|
|
|
func loadPinned() async {
|
|
isLoadingPinned = true
|
|
defer { isLoadingPinned = false }
|
|
pinnedDocuments = await fetchPinned()
|
|
}
|
|
|
|
func load(tab: HomeTab) async {
|
|
isLoadingTab = true
|
|
errorMessage = nil
|
|
defer { isLoadingTab = false }
|
|
do {
|
|
let documents = try await fetch(tab: tab)
|
|
set(documents, for: tab)
|
|
hasRemoteChanges = false
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
|
|
}
|
|
}
|
|
|
|
/// Fetches fresh pinned docs and the current tab's documents to compare
|
|
/// against what's displayed, without replacing either. Bails silently
|
|
/// on a fetch failure rather than treating it as "changed" — a
|
|
/// transient network hiccup (or, offline, `listPins` failing outright —
|
|
/// it isn't one of the cached endpoints) shouldn't pop the refresh
|
|
/// banner. This needs the *throwing* pinned-fetch specifically: the
|
|
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
|
|
/// which used to read here as "pins changed" against whatever was
|
|
/// already displayed and falsely popped the banner on every offline
|
|
/// poll.
|
|
func checkForRemoteChanges(tab: HomeTab) async {
|
|
async let freshPinnedTask = fetchPinnedThrowing()
|
|
guard let freshTab = try? await fetch(tab: tab) else { return }
|
|
guard let pinned = try? await freshPinnedTask else { return }
|
|
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
|
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
|
hasRemoteChanges = true
|
|
}
|
|
}
|
|
|
|
private func fetchPinned() async -> [OutlineDocument] {
|
|
(try? await fetchPinnedThrowing()) ?? []
|
|
}
|
|
|
|
/// `pins.list` only returns pin records, not the documents themselves —
|
|
/// fetches each pinned document individually. Pins are a small curated
|
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
|
/// where it wouldn't be in the sidebar — but they're fetched concurrently
|
|
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
|
|
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
|
|
/// own cached-read/retry path either way.
|
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
|
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
|
let client = apiClient
|
|
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
|
|
for pin in pins {
|
|
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
|
|
}
|
|
var result: [String: OutlineDocument] = [:]
|
|
for await (id, document) in group {
|
|
if let document { result[id] = document }
|
|
}
|
|
return result
|
|
}
|
|
// Preserve pins.list's own order rather than task-completion order.
|
|
return pins.compactMap { documentsByID[$0.documentId] }
|
|
}
|
|
|
|
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
|
switch tab {
|
|
case .recentlyViewed:
|
|
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
|
|
case .popular:
|
|
// `sort: "viewCount"` was a guess and the server rejected it
|
|
// outright ("sort: Invalid input") — sort is validated
|
|
// server-side against a fixed set, not free-form like the
|
|
// vendored spec's typing implies. Same conclusion as
|
|
// `CollectionTab.popular`: there's no real popularity
|
|
// ranking exposed via the REST API, so this falls back to
|
|
// the default list order rather than guessing again.
|
|
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
|
case .recentlyUpdated:
|
|
return try await apiClient.documentsList(
|
|
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
|
|
)
|
|
case .createdByMe:
|
|
let userId = try await resolveCurrentUserID()
|
|
return try await apiClient.documentsList(
|
|
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
|
)
|
|
case .drafts:
|
|
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
|
|
}
|
|
}
|
|
|
|
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
|
|
switch tab {
|
|
case .recentlyViewed: recentlyViewed = documents
|
|
case .popular: popular = documents
|
|
case .recentlyUpdated: recentlyUpdated = documents
|
|
case .createdByMe: createdByMe = documents
|
|
case .drafts: drafts = documents
|
|
}
|
|
}
|
|
|
|
private func resolveCurrentUserID() async throws -> String {
|
|
if let currentUserID { return currentUserID }
|
|
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
|
|
currentUserID = user.id
|
|
return user.id
|
|
}
|
|
|
|
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
|
|
documents
|
|
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
|
|
.sorted()
|
|
.joined(separator: "|")
|
|
}
|
|
}
|