diff --git a/CODEOWNERS b/CODEOWNERS index f21e507..a24f154 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 676f2e4..3a7d250 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -66,9 +66,15 @@ 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. - ToolbarItem(placement: .primaryAction) { - contextualSearchField + // 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 @@ -82,6 +88,8 @@ struct ContentView_macOS: View { private func goHome() { globalSearchQuery = "" + contextualSearchQuery = "" + isContextualSearchExpanded = false selectedCollection = nil isShowingHome = true replaceDocumentPath(with: []) diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift index 1866af7..aca952d 100644 --- a/Outpost/Features/Home/HomeView.swift +++ b/Outpost/Features/Home/HomeView.swift @@ -29,19 +29,29 @@ struct HomeView: View { } var body: some View { - // 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 - // when there's nothing pinned so the tabs get the full height. - GeometryReader { proxy in - VStack(spacing: 0) { - if isShowingPinnedSection { - pinnedSection - .frame(height: max(proxy.size.height / 2, 180)) - Divider() + 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 + // when there's nothing pinned so the tabs get the full height. + GeometryReader { proxy in + VStack(spacing: 0) { + if isShowingPinnedSection { + pinnedSection + .frame(height: max(proxy.size.height / 2, 180)) + Divider() + } + tabSection + .frame(maxWidth: .infinity, maxHeight: .infinity) } - tabSection - .frame(maxWidth: .infinity, maxHeight: .infinity) } } .toolbar { @@ -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 { diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 5b513ac..d1888ce 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -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,37 +46,90 @@ final class HomeViewModel { errorMessage = nil defer { isLoadingTab = false } do { - switch tab { - case .recentlyViewed: - recentlyViewed = 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. - popular = try await apiClient.documentsList(DocumentsListRequest(limit: 25)) - case .recentlyUpdated: - recentlyUpdated = try await apiClient.documentsList( - DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25) - ) - case .createdByMe: - let userId = try await resolveCurrentUserID() - createdByMe = try await apiClient.documentsList( - DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25) - ) - } + 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: + 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) + ) + } + } + + 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 + } + } + private func resolveCurrentUserID() async throws -> String { if let currentUserID { return currentUserID } let user = 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: "|") + } }