fix(home): remove dead search icon, add staleness polling and retry
Three parity gaps found reviewing Home against CollectionOverviewView: - The toolbar's contextual search icon rendered on Home but contextualSearchQuery is only ever read by CollectionOverviewView — clicking it and typing did nothing. Hidden specifically on the Home landing page per explicit request (sidebar's global search already covers this); goHome() also clears the stale query/expanded state so it can't leak back in when returning to a collection. - Home had no periodic remote-changes check, unlike CollectionsTreeView and CollectionOverviewView. Added the same 45s-poll + banner pattern, fingerprinting pinned docs and the current tab's docs against fresh fetches; bails silently on a fetch failure instead of false-positive triggering the banner. - Tab load errors showed a message but no way to retry short of switching tabs and back. Added a Retry button matching the collection document list's. Also hardens CODEOWNERS ahead of going public: kept `* @psmattas` as the catch-all but pinned supply-chain/governance/CI paths (Package manifests, .gitea/, xcodeproj build settings, scripts/, LICENSE, CONTRIBUTING/SECURITY/SETUP, CLAUDE.md, docs/ARCHITECTURE.md) explicitly to @psmattas so they stay owner-gated even if `*` opens up to other contributors later.
This commit is contained in:
+32
@@ -1,5 +1,37 @@
|
||||
# Outpost Code Owners
|
||||
# These users are automatically requested for review on PRs.
|
||||
# Format: path @username
|
||||
#
|
||||
# Rules are evaluated in order, last match wins — so the specific paths
|
||||
# below stay pinned to @psmattas even if `*` is ever opened up to other
|
||||
# contributors/reviewers as the project grows. These are the
|
||||
# supply-chain, governance, and CI-relevant files where an accidental or
|
||||
# malicious change has outsized blast radius for a public repo.
|
||||
|
||||
* @psmattas
|
||||
|
||||
# Repo governance / legal — changes here affect every contributor.
|
||||
/LICENSE @psmattas
|
||||
/CODEOWNERS @psmattas
|
||||
/CONTRIBUTING.md @psmattas
|
||||
/SECURITY.md @psmattas
|
||||
/SETUP.md @psmattas
|
||||
|
||||
# CI, issue/PR automation, review requirements — tampering here can
|
||||
# bypass the protections this very file is trying to set up.
|
||||
/.gitea/ @psmattas
|
||||
|
||||
# Dependency supply chain — a swapped or re-pinned package here can pull
|
||||
# in arbitrary code at build time.
|
||||
/OutlineKit/Package.swift @psmattas
|
||||
/OutlineKit/Package.resolved @psmattas
|
||||
/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @psmattas
|
||||
|
||||
# Build/signing/versioning config and release tooling.
|
||||
/Outpost.xcodeproj/project.pbxproj @psmattas
|
||||
/scripts/ @psmattas
|
||||
|
||||
# Project direction — architecture/scope decisions shouldn't drift via a
|
||||
# drive-by PR.
|
||||
/CLAUDE.md @psmattas
|
||||
/docs/ARCHITECTURE.md @psmattas
|
||||
|
||||
@@ -66,11 +66,17 @@ struct ContentView_macOS: View {
|
||||
// Custom instead of `.searchable`: that modifier always renders a
|
||||
// full-width field, but this is meant to sit alongside the other
|
||||
// per-document toolbar buttons as a plain icon that only expands
|
||||
// into a field once clicked.
|
||||
// into a field once clicked. Hidden on the Home landing page
|
||||
// itself — `contextualSearchQuery` is only ever read by
|
||||
// `CollectionOverviewView`, so on Home it was a dead end: a
|
||||
// user could click it, type, and nothing would happen. The
|
||||
// sidebar's global search already covers "search everything."
|
||||
if !(isShowingHome && documentPath.isEmpty) {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
contextualSearchField
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any explicit collection pick — sidebar click, "Search in
|
||||
// Collection" — means the user has navigated away from Home.
|
||||
.onChange(of: selectedCollection) { _, newValue in
|
||||
@@ -82,6 +88,8 @@ struct ContentView_macOS: View {
|
||||
|
||||
private func goHome() {
|
||||
globalSearchQuery = ""
|
||||
contextualSearchQuery = ""
|
||||
isContextualSearchExpanded = false
|
||||
selectedCollection = nil
|
||||
isShowingHome = true
|
||||
replaceDocumentPath(with: [])
|
||||
|
||||
@@ -29,6 +29,15 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.hasRemoteChanges {
|
||||
RemoteChangesBanner {
|
||||
Task {
|
||||
await viewModel.loadPinned()
|
||||
await viewModel.load(tab: selectedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The pinned section claims roughly the top half when it has
|
||||
// anything to show (scrolling within itself if there are enough
|
||||
// pinned documents to overflow that), and collapses away entirely
|
||||
@@ -44,6 +53,7 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -62,6 +72,13 @@ struct HomeView: View {
|
||||
}
|
||||
.task { await viewModel.loadPinned() }
|
||||
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(45))
|
||||
guard !Task.isCancelled else { break }
|
||||
await viewModel.checkForRemoteChanges(tab: selectedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedSection: some View {
|
||||
@@ -110,6 +127,10 @@ struct HomeView: View {
|
||||
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.load(tab: selectedTab) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if documents.isEmpty {
|
||||
|
||||
@@ -14,6 +14,10 @@ final class HomeViewModel {
|
||||
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?
|
||||
@@ -31,25 +35,10 @@ final class HomeViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pins.list` is speculative (see `OutlinePin`) and 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.
|
||||
func loadPinned() async {
|
||||
isLoadingPinned = true
|
||||
defer { isLoadingPinned = false }
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
||||
pinnedDocuments = []
|
||||
return
|
||||
}
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
documents.append(document)
|
||||
}
|
||||
}
|
||||
pinnedDocuments = documents
|
||||
pinnedDocuments = await fetchPinned()
|
||||
}
|
||||
|
||||
func load(tab: HomeTab) async {
|
||||
@@ -57,9 +46,49 @@ final class HomeViewModel {
|
||||
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 shouldn't pop the refresh banner.
|
||||
func checkForRemoteChanges(tab: HomeTab) async {
|
||||
async let freshPinned = fetchPinned()
|
||||
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||
let pinned = await freshPinned
|
||||
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||
hasRemoteChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
||||
return []
|
||||
}
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
documents.append(document)
|
||||
}
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
||||
switch tab {
|
||||
case .recentlyViewed:
|
||||
recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25)
|
||||
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
|
||||
@@ -68,19 +97,25 @@ final class HomeViewModel {
|
||||
// `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.
|
||||
popular = try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
||||
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
||||
case .recentlyUpdated:
|
||||
recentlyUpdated = try await apiClient.documentsList(
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .createdByMe:
|
||||
let userId = try await resolveCurrentUserID()
|
||||
createdByMe = try await apiClient.documentsList(
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +125,11 @@ final class HomeViewModel {
|
||||
currentUserID = user.id
|
||||
return user.id
|
||||
}
|
||||
|
||||
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
|
||||
documents
|
||||
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
|
||||
.sorted()
|
||||
.joined(separator: "|")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user