From c3083bf0c0d95cbfe32d6112833be4bead73ff91 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:03:17 +0100 Subject: [PATCH 1/7] feat: Home page + universal New Document dialog Home replaces "auto-select first collection" as the landing state - new toolbar Home button, Home pill in the breadcrumb, and the sidebar no longer picks a collection for you on launch. Home page: - Pinned docs as a card grid (pins.list -> per-document fetch, since the speculative pins.list response only carries pin records, not documents - N+1 is acceptable here since pins are a small curated set, unlike a full collection tree) - Recently Viewed (documents.viewed), Recently Updated and Created by Me (documents.list with sort/direction/userId - new richer DocumentsListRequest alongside the existing simple listDocuments, left untouched for its callers), and Popular (best-effort sort: "viewCount" - no confirmed popularity key in the vendored spec, worth eyeballing against a real server) - New Document button in Home's own toolbar New Document is now one shared dialog (NewDocumentSheet, mirrors MoveDocumentSheet's collection+parent picker) instead of three separate call sites that silently created "Untitled" instantly: sidebar collection's New Document, sidebar document's New Document, and the reader's toolbar button + menu item all open it now, pre-filled with whatever context they were opened from. OutlineKit: documents.viewed, richer documents.list filtering, with test coverage. --- .../OutlineKit/Core/OutlineAPIClient.swift | 4 + .../OutlineKit/LiveOutlineAPIClient.swift | 13 ++ .../Requests/DocumentsListRequest.swift | 29 +++ .../LiveOutlineAPIClientTests.swift | 59 ++++++ .../CollectionDocumentsOutline.swift | 28 +-- .../Collections/CollectionTreeRow.swift | 21 +-- .../Collections/CollectionsTreeView.swift | 7 - .../Collections/ContentView_macOS.swift | 52 +++++- .../Collections/DocumentReaderView.swift | 25 +-- .../Collections/NewDocumentSheet.swift | 169 ++++++++++++++++++ Outpost/Features/Home/DocumentCardView.swift | 43 +++++ Outpost/Features/Home/HomeTab.swift | 10 ++ Outpost/Features/Home/HomeView.swift | 122 +++++++++++++ Outpost/Features/Home/HomeViewModel.swift | 94 ++++++++++ 14 files changed, 612 insertions(+), 64 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift create mode 100644 Outpost/Features/Collections/NewDocumentSheet.swift create mode 100644 Outpost/Features/Home/DocumentCardView.swift create mode 100644 Outpost/Features/Home/HomeTab.swift create mode 100644 Outpost/Features/Home/HomeView.swift create mode 100644 Outpost/Features/Home/HomeViewModel.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 3604545..90d34fb 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -9,6 +9,10 @@ public protocol OutlineAPIClient: Sendable { func documentInfo(id: String) async throws -> OutlineDocument func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] + /// Richer filtering (sort/direction/userId) for the Home page's tabs. See `DocumentsListRequest`. + func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] + /// Documents the current user has recently viewed. Backed by `documents.viewed`. + func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] /// Full-text search with snippets/ranking. Backed by `documents.search`. func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] /// Title-only search — faster, no snippets. Backed by `documents.search_titles`. diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 19a3725..2ef0d1b 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -51,6 +51,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { ) } + public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { + try await post("documents.list", body: request) + } + + public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { + try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit)) + } + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { try await post("documents.search", body: request) } @@ -314,6 +322,11 @@ private struct DocumentListParams: Encodable { let limit: Int } +private struct PaginationParams: Encodable { + let offset: Int + let limit: Int +} + private struct CollectionListParams: Encodable { let offset: Int let limit: Int diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift new file mode 100644 index 0000000..c9ae24a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Richer `documents.list` query than `OutlineAPIClient.listDocuments` covers +/// (that one's kept as-is for its existing simple callers) — adds the +/// sort/direction/userId filters the Home page's tabs need. +public struct DocumentsListRequest: Encodable, Sendable { + public let collectionId: String? + public let userId: String? + public let sort: String? + public let direction: String? + public let offset: Int + public let limit: Int + + public init( + collectionId: String? = nil, + userId: String? = nil, + sort: String? = nil, + direction: String? = nil, + offset: Int = 0, + limit: Int = 25 + ) { + self.collectionId = collectionId + self.userId = userId + self.sort = sort + self.direction = direction + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 82c518f..f972c50 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(decodedBody.parentDocumentId, "doc-1") } + func testDocumentsListSendsSortDirectionAndUserId() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": [] } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + _ = try await client.documentsList( + DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC") + ) + + struct SentBody: Decodable { + let userId: String? + let sort: String? + let direction: String? + } + + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody) + XCTAssertEqual(decodedBody.userId, "user-1") + XCTAssertEqual(decodedBody.sort, "updatedAt") + XCTAssertEqual(decodedBody.direction, "DESC") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list") + } + + func testListViewedDocumentsDecodesDocuments() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "doc-1", + "title": "Hello", + "text": "World", + "url": "/doc/hello-doc-1", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z" + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let documents = try await client.listViewedDocuments(offset: 0, limit: 25) + + XCTAssertEqual(documents.first?.id, "doc-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed") + } + func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 6db03f2..190aa98 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -96,6 +96,7 @@ private struct DocumentNodeRow: View { @State private var isShowingInsightsSheet = false @State private var isShowingPresentSheet = false @State private var isShowingSearchSheet = false + @State private var isShowingNewDocumentSheet = false @State private var actionErrorMessage: String? private var isSelected: Bool { @@ -252,6 +253,11 @@ private struct DocumentNodeRow: View { .sheet(isPresented: $isShowingSearchSheet) { DocumentSearchSheet(apiClient: apiClient, document: node.document) } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in + Task { await onDocumentsChanged() } + } + } } @ViewBuilder @@ -303,7 +309,7 @@ private struct DocumentNodeRow: View { Button("Import Document…") {} .disabled(true) Button("New Document") { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } // No `pins.*` endpoint in the API — nothing to back this with. Button("Pin") {} @@ -395,26 +401,6 @@ private struct DocumentNodeRow: View { } } - private func createChildDocument() async { - guard let collectionId = node.document.collectionId else { - actionErrorMessage = "This document isn't in a collection." - return - } - do { - _ = try await apiClient.createDocument( - CreateDocumentRequest( - title: "Untitled", - text: "", - collectionId: collectionId, - parentDocumentId: node.document.id - ) - ) - await onDocumentsChanged() - } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") - } - } - private func delete() async { do { try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id)) diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 1bd159e..a78deef 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -22,6 +22,7 @@ struct CollectionTreeRow: View { @State private var isShowingRenameAlert = false @State private var renameText = "" @State private var isShowingDeleteConfirmation = false + @State private var isShowingNewDocumentSheet = false @State private var actionErrorMessage: String? var body: some View { @@ -92,6 +93,12 @@ struct CollectionTreeRow: View { } message: { Text(actionErrorMessage ?? "") } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in + documentsRefreshToken += 1 + Task { await onCollectionsChanged() } + } + } } @ViewBuilder @@ -103,7 +110,7 @@ struct CollectionTreeRow: View { Divider() Button("New Document") { - Task { await createDocument() } + isShowingNewDocumentSheet = true } Divider() @@ -148,18 +155,6 @@ struct CollectionTreeRow: View { } } - private func createDocument() async { - do { - _ = try await apiClient.createDocument( - CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id) - ) - documentsRefreshToken += 1 - await onCollectionsChanged() - } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") - } - } - private func rename() async { do { _ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText)) diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index fe6bf27..6760297 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -95,13 +95,6 @@ struct CollectionsTreeView: View { } .task { await viewModel.load() - // Stand-in for Outline's own configured "Start view" — we don't have - // a confirmed schema for `team.preferences` to read the actual - // setting, so this defaults to the first collection instead of - // landing on an empty "No Collection Selected" placeholder. - if selectedCollection == nil, let first = viewModel.collections.first { - selectedCollection = first - } } // A document opened from outside the sidebar (detail pane's list, // global search) wouldn't otherwise expand its collection here, so diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 4d650f8..610f93b 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -4,6 +4,10 @@ import OutlineKit struct ContentView_macOS: View { @Environment(SessionStore.self) private var session + /// The landing state — no collection selected yet is what Home actually + /// means, so this starts `true` rather than auto-selecting the first + /// collection the way this used to work. + @State private var isShowingHome = true @State private var selectedCollection: OutlineCollection? /// The real navigation stack, root to leaf — also the source of truth for /// the toolbar breadcrumb, so the two can't drift out of sync. @@ -43,6 +47,14 @@ struct ContentView_macOS: View { // mutually exclusive: whenever a document's pushed (back button // visible), this shows the document hierarchy instead of falling // back to the workspace badge. + ToolbarItem(placement: .navigation) { + Button { + goHome() + } label: { + Image(systemName: "house") + } + .help("Home") + } ToolbarItem(placement: .navigation) { leadingToolbarContent } @@ -54,6 +66,20 @@ struct ContentView_macOS: View { contextualSearchField } } + // Any explicit collection pick — sidebar click, "Search in + // Collection" — means the user has navigated away from Home. + .onChange(of: selectedCollection) { _, newValue in + if newValue != nil { + isShowingHome = false + } + } + } + + private func goHome() { + globalSearchQuery = "" + selectedCollection = nil + isShowingHome = true + replaceDocumentPath(with: []) } @ViewBuilder @@ -106,12 +132,19 @@ struct ContentView_macOS: View { Image(systemName: "magnifyingglass") Text("Search") } - } else if !documentPath.isEmpty, let selectedCollection { - // Collection → every ancestor (icon only) → current document - // (icon + full title) — ancestors stay icon-only so a deep - // chain doesn't blow out the toolbar width. + } else if !documentPath.isEmpty { + // Origin (collection, or Home if opened from there) → every + // ancestor (icon only) → current document (icon + full + // title) — ancestors stay icon-only so a deep chain doesn't + // blow out the toolbar width. Checked before `isShowingHome` + // since opening a document from Home still leaves that flag + // set — the pushed document should win either way. HStack(spacing: 6) { - CollectionRowView(collection: selectedCollection) + if let selectedCollection { + CollectionRowView(collection: selectedCollection) + } else { + Image(systemName: "house.fill") + } ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in Image(systemName: "chevron.right") @@ -130,6 +163,11 @@ struct ContentView_macOS: View { } } } + } else if isShowingHome { + HStack(spacing: 6) { + Image(systemName: "house.fill") + Text("Home") + } } else if let selectedCollection { CollectionRowView(collection: selectedCollection) } else { @@ -205,6 +243,8 @@ struct ContentView_macOS: View { Group { if !trimmedGlobalQuery.isEmpty { GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) + } else if isShowingHome { + HomeView(apiClient: apiClient, onOpenDocument: openDocument) } else if let selectedCollection { CollectionOverviewView( apiClient: apiClient, @@ -233,7 +273,7 @@ struct ContentView_macOS: View { ) } } - .id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search") + .id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search") } else { ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark") } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index cbd8786..d4bb4e5 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -33,6 +33,7 @@ struct DocumentReaderView: View { @State private var isShowingPresentSheet = false @State private var isShowingSearchSheet = false @State private var isShowingShareSheet = false + @State private var isShowingNewDocumentSheet = false @State private var actionErrorMessage: String? init( @@ -116,7 +117,7 @@ struct DocumentReaderView: View { .disabled(viewModel.isSaving) Button { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } label: { Image(systemName: "doc.badge.plus") } @@ -200,6 +201,11 @@ struct DocumentReaderView: View { .sheet(isPresented: $isShowingShareSheet) { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in + onOpenChild(child) + } + } } @ViewBuilder @@ -288,7 +294,7 @@ struct DocumentReaderView: View { Button("Import Document…") {} .disabled(true) Button("New Document") { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } Button(viewModel.isPinned ? "Unpin" : "Pin") { Task { await togglePin() } @@ -431,21 +437,6 @@ struct DocumentReaderView: View { } } - private func createChildDocument() async { - guard let collectionId = viewModel.collectionId else { - actionErrorMessage = "This document isn't in a collection." - return - } - do { - let child = try await apiClient.createDocument( - CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId) - ) - onOpenChild(child) - } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") - } - } - private func download() async { do { let markdown = try await apiClient.exportDocument(id: viewModel.documentId) diff --git a/Outpost/Features/Collections/NewDocumentSheet.swift b/Outpost/Features/Collections/NewDocumentSheet.swift new file mode 100644 index 0000000..3a8d1a8 --- /dev/null +++ b/Outpost/Features/Collections/NewDocumentSheet.swift @@ -0,0 +1,169 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// The one shared "create a document" dialog — every "New Document" entry +/// point (sidebar collection, sidebar document, reader toolbar/menu, Home) +/// opens this instead of silently creating an "Untitled" document. Mirrors +/// `MoveDocumentSheet`'s collection+parent picker pattern. +@MainActor +struct NewDocumentSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + /// Pre-selected collection — e.g. opened from a specific collection's + /// "New Document". `nil` when opened from Home, where nothing is + /// pre-selected and the user must choose. + let initialCollectionID: String? + /// Pre-selected parent — e.g. opened from a document's "New Document", + /// which creates a child of that document. + let initialParentDocument: OutlineDocument? + let onCreated: (OutlineDocument) -> Void + + @State private var title = "" + @State private var collections: [OutlineCollection] = [] + @State private var selectedCollectionID: String? + @State private var rootDocuments: [OutlineDocument] = [] + @State private var selectedParentID: String? + @State private var isLoadingCollections = false + @State private var isLoadingDestinationDocuments = false + @State private var isCreating = false + @State private var errorMessage: String? + @FocusState private var isTitleFocused: Bool + + init( + apiClient: OutlineAPIClient, + initialCollectionID: String? = nil, + initialParentDocument: OutlineDocument? = nil, + onCreated: @escaping (OutlineDocument) -> Void + ) { + self.apiClient = apiClient + self.initialCollectionID = initialCollectionID + self.initialParentDocument = initialParentDocument + self.onCreated = onCreated + } + + /// `rootDocuments` is only root-level (mirroring `MoveDocumentSheet`'s + /// intentionally shallow picker) — if the initial parent is nested + /// deeper than that, it wouldn't otherwise appear as a selectable option + /// even though it's already the selection. + private var parentOptions: [OutlineDocument] { + var options = rootDocuments + if let initialParentDocument, + initialParentDocument.collectionId == selectedCollectionID, + !options.contains(where: { $0.id == initialParentDocument.id }) { + options.insert(initialParentDocument, at: 0) + } + return options + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New Document") + .font(.headline) + + TextField("Title", text: $title) + .textFieldStyle(.roundedBorder) + .focused($isTitleFocused) + .onSubmit { Task { await create() } } + + if isLoadingCollections { + ProgressView().frame(maxWidth: .infinity) + } else { + Picker("Collection", selection: $selectedCollectionID) { + Text("Choose a collection").tag(String?.none) + ForEach(collections) { collection in + Text(collection.name).tag(Optional(collection.id)) + } + } + .labelsHidden() + + Picker("Location", selection: $selectedParentID) { + Text("Collection root").tag(String?.none) + ForEach(parentOptions) { candidate in + Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id)) + } + } + .labelsHidden() + .disabled(selectedCollectionID == nil || isLoadingDestinationDocuments) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Create") { + Task { await create() } + } + .keyboardShortcut(.defaultAction) + .disabled(selectedCollectionID == nil || isCreating) + } + } + .padding(20) + .frame(width: 380) + .task { + await loadCollections() + selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id + selectedParentID = initialParentDocument?.id + isTitleFocused = true + } + .task(id: selectedCollectionID) { + await loadRootDocuments() + } + } + + private func loadCollections() async { + isLoadingCollections = true + defer { isLoadingCollections = false } + do { + collections = try await apiClient.listCollections(offset: 0, limit: 100) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.") + } + } + + private func loadRootDocuments() async { + guard let selectedCollectionID else { + rootDocuments = [] + return + } + isLoadingDestinationDocuments = true + defer { isLoadingDestinationDocuments = false } + do { + rootDocuments = try await apiClient.listDocuments( + collectionId: selectedCollectionID, + parentDocumentId: nil, + offset: 0, + limit: 100 + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.") + } + } + + private func create() async { + guard let selectedCollectionID else { return } + isCreating = true + defer { isCreating = false } + do { + let document = try await apiClient.createDocument( + CreateDocumentRequest( + title: title.isEmpty ? "Untitled" : title, + text: "", + collectionId: selectedCollectionID, + parentDocumentId: selectedParentID + ) + ) + onCreated(document) + dismiss() + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't create this document.") + } + } +} +#endif diff --git a/Outpost/Features/Home/DocumentCardView.swift b/Outpost/Features/Home/DocumentCardView.swift new file mode 100644 index 0000000..28692a1 --- /dev/null +++ b/Outpost/Features/Home/DocumentCardView.swift @@ -0,0 +1,43 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct DocumentCardView: View { + @Environment(StarStore.self) private var starStore + let document: OutlineDocument + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + if let emoji = document.emoji { + Text(emoji) + .font(.title2) + } else { + Image(systemName: "doc.text") + .font(.title3) + .foregroundStyle(.secondary) + } + Spacer() + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + } + } + + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(document.updatedAt, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10)) + } +} +#endif diff --git a/Outpost/Features/Home/HomeTab.swift b/Outpost/Features/Home/HomeTab.swift new file mode 100644 index 0000000..44faf3d --- /dev/null +++ b/Outpost/Features/Home/HomeTab.swift @@ -0,0 +1,10 @@ +import Foundation + +enum HomeTab: String, CaseIterable, Identifiable { + case recentlyViewed = "Recently Viewed" + case popular = "Popular" + case recentlyUpdated = "Recently Updated" + case createdByMe = "Created by Me" + + var id: String { rawValue } +} diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift new file mode 100644 index 0000000..7a22831 --- /dev/null +++ b/Outpost/Features/Home/HomeView.swift @@ -0,0 +1,122 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct HomeView: View { + let apiClient: OutlineAPIClient + let onOpenDocument: (OutlineDocument) -> Void + + @State private var viewModel: HomeViewModel + @State private var selectedTab: HomeTab = .recentlyViewed + @State private var isShowingNewDocumentSheet = false + + private let gridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] + + init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) { + self.apiClient = apiClient + self.onOpenDocument = onOpenDocument + _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + if isShowingPinnedSection { + pinnedSection + } + tabSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isShowingNewDocumentSheet = true + } label: { + Image(systemName: "doc.badge.plus") + } + .help("New Document") + } + } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient) { document in + onOpenDocument(document) + } + } + .task { await viewModel.loadPinned() } + .task(id: selectedTab) { await viewModel.load(tab: selectedTab) } + } + + private var isShowingPinnedSection: Bool { + viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty + } + + private var pinnedSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Pinned") + .font(.title3.weight(.semibold)) + + if viewModel.isLoadingPinned { + ProgressView() + .frame(maxWidth: .infinity) + } else { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(viewModel.pinnedDocuments) { document in + Button { + onOpenDocument(document) + } label: { + DocumentCardView(document: document) + } + .buttonStyle(.plain) + } + } + } + } + } + + private var tabSection: some View { + VStack(alignment: .leading, spacing: 16) { + Picker("", selection: $selectedTab) { + ForEach(HomeTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 520) + + let documents = viewModel.documents(for: selectedTab) + + if viewModel.isLoadingTab && documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Nothing to show here yet.") + ) + } else { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(documents) { document in + Button { + onOpenDocument(document) + } label: { + DocumentCardView(document: document) + } + .buttonStyle(.plain) + } + } + } + } + } +} +#endif diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift new file mode 100644 index 0000000..5e49268 --- /dev/null +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -0,0 +1,94 @@ +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] = [] + + var isLoadingPinned = false + var isLoadingTab = false + var errorMessage: String? + + 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 + } + } + + /// `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 + } + + func load(tab: HomeTab) async { + isLoadingTab = true + errorMessage = nil + defer { isLoadingTab = false } + do { + switch tab { + case .recentlyViewed: + recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25) + case .popular: + // Best-effort: the vendored spec's `Sorting.sort` is a + // free-form string, not an enum, with no documented + // popularity key. If the server doesn't recognize + // "viewCount" it most likely just falls back to a default + // order rather than erroring - worth eyeballing against a + // real server. + popular = try await apiClient.documentsList( + DocumentsListRequest(sort: "viewCount", direction: "DESC", 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) + ) + } + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.") + } + } + + private func resolveCurrentUserID() async throws -> String { + if let currentUserID { return currentUserID } + let user = try await apiClient.currentUser() + currentUserID = user.id + return user.id + } +} From 23193034e6bd6fc83eb18c726f759ee8eff1881c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:16:27 +0100 Subject: [PATCH 2/7] fix(home): distinct pinned card style, matching tab bar, drop broken sort - Pinned section uses a new dense PinnedDocumentCard (single-line, explicit pin glyph) instead of the same tall card as the tab grids - it's meant for a quick scan of a small curated set, not browsing, and needed to actually show a pin so pinned docs are recognizable at a glance. - Tab bar now matches CollectionOverviewView.tabBar's exact style instead of the native segmented picker. - Popular tab's sort: "viewCount" was a guess and the server rejected it outright ("sort: Invalid input") - sort is validated against a fixed set server-side, not free-form like the vendored spec's typing implies. Falls back to default order now, same conclusion already reached for CollectionTab.popular - no real popularity ranking is exposed via the REST API. - Layout: pinned section now claims roughly the top half of the page (scrolling within itself if there are more pinned docs than fit) when there's anything pinned, collapsing away entirely otherwise so the tabs get full height. --- Outpost/Features/Home/HomeView.swift | 141 +++++++++++------- Outpost/Features/Home/HomeViewModel.swift | 17 +-- .../Features/Home/PinnedDocumentCard.swift | 46 ++++++ 3 files changed, 145 insertions(+), 59 deletions(-) create mode 100644 Outpost/Features/Home/PinnedDocumentCard.swift diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift index 7a22831..ffc868f 100644 --- a/Outpost/Features/Home/HomeView.swift +++ b/Outpost/Features/Home/HomeView.swift @@ -10,7 +10,8 @@ struct HomeView: View { @State private var selectedTab: HomeTab = .recentlyViewed @State private var isShowingNewDocumentSheet = false - private let gridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] + private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)] + private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) { self.apiClient = apiClient @@ -18,16 +19,25 @@ struct HomeView: View { _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) } + private var isShowingPinnedSection: Bool { + viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty + } + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 28) { + // 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) } - .padding(24) - .frame(maxWidth: .infinity, alignment: .leading) } .toolbar { ToolbarItem(placement: .primaryAction) { @@ -48,75 +58,106 @@ struct HomeView: View { .task(id: selectedTab) { await viewModel.load(tab: selectedTab) } } - private var isShowingPinnedSection: Bool { - viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty - } - private var pinnedSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Pinned") .font(.title3.weight(.semibold)) + .padding(.horizontal, 24) + .padding(.top, 20) if viewModel.isLoadingPinned { ProgressView() - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(viewModel.pinnedDocuments) { document in - Button { - onOpenDocument(document) - } label: { - DocumentCardView(document: document) + ScrollView { + LazyVGrid(columns: pinnedGridColumns, spacing: 8) { + ForEach(viewModel.pinnedDocuments) { document in + Button { + onOpenDocument(document) + } label: { + PinnedDocumentCard(document: document) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal, 24) + .padding(.bottom, 16) } } } } private var tabSection: some View { - VStack(alignment: .leading, spacing: 16) { - Picker("", selection: $selectedTab) { - ForEach(HomeTab.allCases) { tab in - Text(tab.rawValue).tag(tab) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 520) + VStack(alignment: .leading, spacing: 0) { + tabBar + + Divider() let documents = viewModel.documents(for: selectedTab) - if viewModel.isLoadingTab && documents.isEmpty { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 40) - } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { - ContentUnavailableView { - Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") - } description: { - Text(errorMessage) - } - } else if documents.isEmpty { - ContentUnavailableView( - "No Documents", - systemImage: "doc.text", - description: Text("Nothing to show here yet.") - ) - } else { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(documents) { document in - Button { - onOpenDocument(document) - } label: { - DocumentCardView(document: document) + Group { + if viewModel.isLoadingTab && documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Nothing to show here yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: tabGridColumns, spacing: 12) { + ForEach(documents) { document in + Button { + onOpenDocument(document) + } label: { + DocumentCardView(document: document) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) + .padding(24) } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + // Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the + // native `.pickerStyle(.segmented)` this started with. + private var tabBar: some View { + HStack(spacing: 4) { + Spacer(minLength: 0) + ForEach(HomeTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Text(tab.rawValue) + .font(.callout.weight(selectedTab == tab ? .semibold : .regular)) + .foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } } #endif diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 5e49268..5b513ac 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -61,15 +61,14 @@ final class HomeViewModel { case .recentlyViewed: recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25) case .popular: - // Best-effort: the vendored spec's `Sorting.sort` is a - // free-form string, not an enum, with no documented - // popularity key. If the server doesn't recognize - // "viewCount" it most likely just falls back to a default - // order rather than erroring - worth eyeballing against a - // real server. - popular = try await apiClient.documentsList( - DocumentsListRequest(sort: "viewCount", direction: "DESC", limit: 25) - ) + // `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) diff --git a/Outpost/Features/Home/PinnedDocumentCard.swift b/Outpost/Features/Home/PinnedDocumentCard.swift new file mode 100644 index 0000000..04d19bc --- /dev/null +++ b/Outpost/Features/Home/PinnedDocumentCard.swift @@ -0,0 +1,46 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Deliberately distinct from `DocumentCardView` — the pinned section is for +/// a quick scan of a small curated set, not browsing, so this is a dense +/// single-line row rather than a tall card, with an explicit pin glyph so +/// it doesn't read the same as the tab grids below it. +struct PinnedDocumentCard: View { + @Environment(StarStore.self) private var starStore + let document: OutlineDocument + + var body: some View { + HStack(spacing: 10) { + if let emoji = document.emoji { + Text(emoji) + .font(.title3) + } else { + Image(systemName: "doc.text") + .font(.body) + .foregroundStyle(.secondary) + } + + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.callout.weight(.medium)) + .lineLimit(1) + + Spacer(minLength: 0) + + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + } +} +#endif From c1810f38f1c1044d56937272a88f935f3dfff4b6 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:32:59 +0100 Subject: [PATCH 3/7] fix(pins): decode pins.list correctly, use collectionId:nil for Pin to Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pins.list's real response is {data: {pins: [...], documents: [...]}}, not a bare array — confirmed against a live server's network traffic. Decoding straight to [OutlinePin] threw on every call, and call sites swallow that with try?, so pins never showed up anywhere (including docs already pinned via the real web app). Also: the reader's Pin action was sending the document's own collectionId, which is "Pin to Collection" — a different action from "Pin to Home" (collectionId: null), which is what the web app's "Pin to Home" menu item actually does and what the Home page's pinned section filters for. --- .../OutlineKit/LiveOutlineAPIClient.swift | 12 +++++++- .../OutlineKit/Models/OutlinePin.swift | 12 ++++---- .../Requests/CreatePinRequest.swift | 3 +- .../OutlineKit/Requests/ListPinsRequest.swift | 2 +- .../LiveOutlineAPIClientTests.swift | 28 +++++++++++++++++++ .../CollectionDocumentsOutline.swift | 5 +++- .../Collections/DocumentReaderView.swift | 2 +- .../Collections/DocumentReaderViewModel.swift | 6 ++-- 8 files changed, 57 insertions(+), 13 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 2ef0d1b..437b7b5 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -137,7 +137,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { - try await post("pins.list", body: request) + // `data` here is `{ pins: [...], documents: [...] }`, not a bare + // array — confirmed against a live server. Decoding straight to + // `[OutlinePin]` throws on every call, which `try?` at call sites + // swallows silently, so pins never showed up anywhere. + let payload: PinsListPayload = try await post("pins.list", body: request) + return payload.pins } public func deletePin(id: String) async throws { @@ -298,6 +303,11 @@ private struct ListStarsResponse: Decodable { let stars: [OutlineStar] } +private struct PinsListPayload: Decodable { + let pins: [OutlinePin] + let documents: [OutlineDocument] +} + private struct StarIDParams: Encodable { let id: String } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift index 1637c40..d6a66ec 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift @@ -1,11 +1,11 @@ import Foundation -/// A document pinned to the top of a collection (or the team home), backed by -/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) -/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's -/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed -/// from general knowledge of Outline's API, not verified against this spec; -/// treat field names as best-effort until confirmed against a live server. +/// A document pinned to the top of a collection, or to team Home when +/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI +/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but +/// confirmed real against a live server's network traffic. `collectionId: nil` +/// is what "Pin to Home" actually sends; a non-nil value is "Pin to +/// Collection", a distinct action. public struct OutlinePin: Decodable, Identifiable, Sendable { public let id: String public let documentId: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift index e8bc0d7..29e2701 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -1,6 +1,7 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to +/// Collection" — these are distinct actions on the real server. public struct CreatePinRequest: Encodable, Sendable { public let documentId: String public let collectionId: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift index 29dc61f..b6a8d5d 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift @@ -1,6 +1,6 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` lists Home pins only. public struct ListPinsRequest: Encodable, Sendable { public let collectionId: String? diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index f972c50..7ac7075 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -735,6 +735,34 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") } + func testListPinsDecodesNestedPinsArray() async throws { + // Real shape confirmed against a live server: `data` is + // `{ pins: [...], documents: [...] }`, not a bare array. + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "pagination": { "limit": 25, "offset": 0 }, + "data": { + "pins": [ + { "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" } + ], + "documents": [] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let pins = try await client.listPins(ListPinsRequest(collectionId: nil)) + + XCTAssertEqual(pins.first?.id, "pin-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list") + } + func testCreateSubscriptionDecodesSubscription() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 190aa98..258b93b 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -311,7 +311,10 @@ private struct DocumentNodeRow: View { Button("New Document") { isShowingNewDocumentSheet = true } - // No `pins.*` endpoint in the API — nothing to back this with. + // `pins.*` is real and works (see `OutlinePin`), but "Pin to + // Collection" (non-nil `collectionId`) is distinct from the reader + // toolbar's "Pin to Home" and needs its own per-row state/N+1 + // consideration for a whole tree — not wired here yet. Button("Pin") {} .disabled(true) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index d4bb4e5..5bc5353 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -296,7 +296,7 @@ struct DocumentReaderView: View { Button("New Document") { isShowingNewDocumentSheet = true } - Button(viewModel.isPinned ? "Unpin" : "Pin") { + Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 47bc3c2..4ec090b 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -75,7 +75,9 @@ final class DocumentReaderViewModel { } func loadPinAndSubscriptionState() async { - if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), + // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to + // Home", not "Pin to Collection" — those are distinct on the server. + if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), let match = pins.first(where: { $0.documentId == documentId }) { isPinned = true pinId = match.id @@ -110,7 +112,7 @@ final class DocumentReaderViewModel { throw error } } else { - let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) + let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil)) pinId = pin.id isPinned = true } From b45d72238c275d340eb46c86e02e21d91d63b712 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:41:20 +0100 Subject: [PATCH 4/7] fix(reader): real toggle checkmarks for Subscribed/Viewer Insights/Full Width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch never got the toggle-menu fixes that landed on chore/verify-pins-subscriptions-api — Subscribed/Viewer Insights/Full Width were plain Buttons with no on/off indicator, and the menu didn't force a rebuild on state change, so even a real Toggle would've shown a stale checkmark until the view was torn down and rebuilt. Ported that branch's fixes: Subscribed/Viewer Insights/Full Width are now real Toggle views bound to observable state; the overflow Menu is keyed to a .id() built from every toggle-backed state so SwiftUI actually re-evaluates the checkmarks; viewer avatars are gated on isInsightsEnabled so they hide immediately when insights are turned off. Also dropped the dead documentEmbeds field (confirmed no per-document embeds endpoint exists) — Enable Embeds is a disabled button with an explanation, matching the other branch. --- .../Requests/UpdateDocumentRequest.swift | 10 +-- .../Collections/DocumentReaderView.swift | 72 ++++++++++++------- .../Collections/DocumentReaderViewModel.swift | 33 ++++++--- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift index b7a5611..5e38781 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -7,12 +7,6 @@ public struct UpdateDocumentRequest: Encodable, Sendable { public let append: Bool? public let fullWidth: Bool? public let insightsEnabled: Bool? - /// Not in the vendored spec's `Document`/`documents.update` shape at all - /// (only a workspace-level `documentEmbeds` flag exists there) — included - /// speculatively since the field may exist on newer self-hosted servers. - /// Unrecognized fields are typically ignored server-side rather than - /// rejected, so this is low-risk even if unsupported. - public let documentEmbeds: Bool? public init( id: String, @@ -20,8 +14,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable { text: String? = nil, append: Bool? = nil, fullWidth: Bool? = nil, - insightsEnabled: Bool? = nil, - documentEmbeds: Bool? = nil + insightsEnabled: Bool? = nil ) { self.id = id self.title = title @@ -29,6 +22,5 @@ public struct UpdateDocumentRequest: Encodable, Sendable { self.append = append self.fullWidth = fullWidth self.insightsEnabled = insightsEnabled - self.documentEmbeds = documentEmbeds } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 5bc5353..0772981 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -128,12 +128,22 @@ struct DocumentReaderView: View { } label: { Image(systemName: "ellipsis.circle") } + // SwiftUI's macOS `Menu` doesn't reliably re-evaluate a + // `Toggle`'s checkmark against updated @Observable state on + // its own — without a fresh `.id()` per state combination, + // toggling Subscribed/Viewer Insights/Full Width kept + // showing the pre-toggle checkmark until the whole view was + // torn down and rebuilt (e.g. navigating away and back). + .id(menuIdentity) } } .task { await viewModel.loadFullContent() } .task { await viewModel.loadPinAndSubscriptionState() } + .task { + await viewModel.loadInsightsEnabledState() + } .task { while !Task.isCancelled { await viewModel.loadViewers() @@ -208,9 +218,25 @@ struct DocumentReaderView: View { } } + /// Every toggle-backed piece of state shown as a checkmark inside + /// `menuContent` — see the `.id()` comment on the `Menu` above. + private var menuIdentity: String { + [ + starStore.isStarred(documentId: viewModel.documentId), + viewModel.isSubscribed, + viewModel.isPinned, + viewModel.isInsightsEnabled ?? false, + viewModel.isFullWidth, + viewModel.isEditing + ].map(String.init).joined(separator: "-") + } + @ViewBuilder private var viewerAvatars: some View { - if !viewModel.viewers.isEmpty { + // Tied to the Viewer Insights toggle — that's the feature this data + // belongs to, so turning it off should hide the avatars immediately + // rather than leaving them showing until the view reloads. + if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty { HStack(spacing: -6) { ForEach(viewModel.viewers.prefix(5)) { viewer in AvatarBadge( @@ -255,9 +281,10 @@ struct DocumentReaderView: View { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { Task { await star() } } - Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { - Task { await toggleSubscription() } - } + Toggle("Subscribed", isOn: Binding( + get: { viewModel.isSubscribed }, + set: { _ in Task { await toggleSubscription() } } + )) Divider() @@ -329,15 +356,20 @@ struct DocumentReaderView: View { Divider() - Button("Enable Viewer Insights") { - Task { await enableInsights() } - } - Button("Enable Embeds") { - Task { await enableEmbeds() } - } - Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { - Task { await toggleFullWidth() } - } + Toggle("Viewer Insights", isOn: Binding( + get: { viewModel.isInsightsEnabled ?? false }, + set: { _ in Task { await toggleInsights() } } + )) + // Confirmed against a live server: there's no per-document embeds + // field. Only a workspace-level setting exists, and that's not + // reachable via the API either (no `team.update` endpoint in the + // vendored spec) — disabled rather than kept as a broken action. + Button("Enable Embeds") {} + .disabled(true) + Toggle("Full Width", isOn: Binding( + get: { viewModel.isFullWidth }, + set: { _ in Task { await toggleFullWidth() } } + )) Divider() @@ -378,19 +410,11 @@ struct DocumentReaderView: View { } } - private func enableInsights() async { + private func toggleInsights() async { do { - try await viewModel.enableViewerInsights() + try await viewModel.toggleViewerInsights() } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") - } - } - - private func enableEmbeds() async { - do { - try await viewModel.enableEmbeds() - } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.") } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 4ec090b..3005440 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -30,6 +30,12 @@ final class DocumentReaderViewModel { private var subscriptionId: String? private(set) var share: OutlineShare? + /// `nil` until checked. Inferred from whether `documents.insights` + /// succeeds or fails — `insightsEnabled` isn't readable back off + /// `Document` in the vendored spec, so there's no direct field to read. + /// This is a heuristic, not confirmed server behavior. + private(set) var isInsightsEnabled: Bool? + let documentId: String private let apiClient: OutlineAPIClient @@ -100,6 +106,15 @@ final class DocumentReaderViewModel { share = try? await apiClient.shareInfo(documentId: documentId) } + func loadInsightsEnabledState() async { + do { + _ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId)) + isInsightsEnabled = true + } catch { + isInsightsEnabled = false + } + } + func togglePin() async throws { if let pinId { self.pinId = nil @@ -170,14 +185,14 @@ final class DocumentReaderViewModel { } } - /// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` - /// in the vendored spec, so there's no state to reflect as a checkmark. - func enableViewerInsights() async throws { - _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) - } - - /// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`. - func enableEmbeds() async throws { - _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true)) + func toggleViewerInsights() async throws { + let newValue = !(isInsightsEnabled ?? false) + isInsightsEnabled = newValue + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue)) + } catch { + isInsightsEnabled = !newValue + throw error + } } } From c2b41ca96067076763e1808767dd5824950d6b5a Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:46:40 +0100 Subject: [PATCH 5/7] fix(sidebar): auto-refresh after creating a doc from Home or the reader toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader's inline "New Document" button and Home's New Document sheet only navigated to the new document — neither had a handle on the sidebar row it landed under, so the tree stayed stale until the periodic remote-changes poll (45s) surfaced the "reload" banner. New Document flows that already have a direct handle on their own sidebar row (right-click a collection, right-click a document) already refreshed correctly and are untouched. Threads a documentsChangedToken from ContentView_macOS down through CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline as externalRefreshToken; every expanded row reloads itself when it bumps, since neither Home nor the reader knows which row (if any) corresponds to where the new document landed. --- .../Collections/CollectionDocumentsOutline.swift | 14 +++++++++++++- .../Features/Collections/CollectionTreeRow.swift | 3 +++ .../Features/Collections/CollectionsTreeView.swift | 7 +++++++ .../Features/Collections/ContentView_macOS.swift | 11 +++++++++-- .../Features/Collections/DocumentReaderView.swift | 10 +++++++++- Outpost/Features/Home/HomeView.swift | 8 +++++++- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 258b93b..1013033 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -12,6 +12,13 @@ struct CollectionDocumentsOutline: View { @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int + /// Bumped from `ContentView_macOS` whenever a document is created from + /// somewhere that has no direct handle on this row — the reader's + /// toolbar "New Document" button and Home's, specifically. Those can't + /// call `onDocumentsChanged()` the way a same-row sheet does, since they + /// don't know which (if any) sidebar row corresponds to where the new + /// document landed, so every expanded row just reloads itself. + let externalRefreshToken: Int let selectedDocumentID: String? /// Full chain from root to the clicked document (inclusive) — lets the /// toolbar render the real hierarchy instead of just the leaf title. @@ -26,6 +33,7 @@ struct CollectionDocumentsOutline: View { collection: OutlineCollection, sortOption: SidebarSortOption, refreshToken: Int, + externalRefreshToken: Int, selectedDocumentID: String?, onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { @@ -33,6 +41,7 @@ struct CollectionDocumentsOutline: View { _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken + self.externalRefreshToken = externalRefreshToken self.selectedDocumentID = selectedDocumentID self.onSelectDocument = onSelectDocument } @@ -62,7 +71,10 @@ struct CollectionDocumentsOutline: View { } } } - .task(id: refreshToken) { await viewModel.load() } + // Combined into one identity rather than two separate `.task(id:)` + // modifiers — each of those fires once unconditionally on first + // appear, so two of them would double the initial load. + .task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() } } } diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index a78deef..515363b 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -11,6 +11,8 @@ struct CollectionTreeRow: View { let isExpanded: Bool let isSelected: Bool let selectedDocumentID: String? + /// See the identical parameter on `CollectionDocumentsOutline`. + let externalRefreshToken: Int let onToggle: () -> Void let onSelectDocument: ([OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -63,6 +65,7 @@ struct CollectionTreeRow: View { collection: collection, sortOption: sortOption, refreshToken: documentsRefreshToken, + externalRefreshToken: externalRefreshToken, selectedDocumentID: selectedDocumentID, onSelectDocument: onSelectDocument ) diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index 6760297..941801a 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -7,6 +7,10 @@ struct CollectionsTreeView: View { @Binding private var selectedCollection: OutlineCollection? let selectedDocumentID: String? @State private var expandedCollectionIDs: Set = [] + /// Bumped from `ContentView_macOS` whenever a document is created + /// somewhere with no direct handle on the sidebar row it belongs + /// under — see the identical parameter on `CollectionDocumentsOutline`. + let externalRefreshToken: Int let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -14,12 +18,14 @@ struct CollectionsTreeView: View { apiClient: OutlineAPIClient, selectedCollection: Binding, selectedDocumentID: String?, + externalRefreshToken: Int, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSearchInCollection: @escaping (OutlineCollection) -> Void ) { _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _selectedCollection = selectedCollection self.selectedDocumentID = selectedDocumentID + self.externalRefreshToken = externalRefreshToken self.onSelectDocument = onSelectDocument self.onSearchInCollection = onSearchInCollection } @@ -81,6 +87,7 @@ struct CollectionsTreeView: View { isExpanded: expandedCollectionIDs.contains(collection.id), isSelected: selectedCollection?.id == collection.id, selectedDocumentID: selectedDocumentID, + externalRefreshToken: externalRefreshToken, onToggle: { toggle(collection) }, onSelectDocument: { chain in onSelectDocument(collection, chain) }, onSearchInCollection: onSearchInCollection, diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 610f93b..89595fe 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -16,6 +16,11 @@ struct ContentView_macOS: View { @State private var contextualSearchQuery = "" @State private var isContextualSearchExpanded = false @FocusState private var isContextualSearchFocused: Bool + /// Bumped whenever a document is created from somewhere with no direct + /// handle on the sidebar row it belongs under (the reader toolbar's and + /// Home's "New Document" buttons) — every expanded sidebar row reloads + /// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`. + @State private var documentsChangedToken = 0 private var trimmedGlobalQuery: String { globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) @@ -191,6 +196,7 @@ struct ContentView_macOS: View { apiClient: apiClient, selectedCollection: $selectedCollection, selectedDocumentID: documentPath.last?.id, + externalRefreshToken: documentsChangedToken, onSelectDocument: selectDocumentChain, onSearchInCollection: searchInCollection ) @@ -244,7 +250,7 @@ struct ContentView_macOS: View { if !trimmedGlobalQuery.isEmpty { GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) } else if isShowingHome { - HomeView(apiClient: apiClient, onOpenDocument: openDocument) + HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 }) } else if let selectedCollection { CollectionOverviewView( apiClient: apiClient, @@ -269,7 +275,8 @@ struct ContentView_macOS: View { if !documentPath.isEmpty { documentPath.removeLast() } - } + }, + onDocumentCreated: { documentsChangedToken += 1 } ) } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 0772981..3ba40aa 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -23,6 +23,11 @@ struct DocumentReaderView: View { /// longer visible in the collection it was opened from, so the reader /// pops itself off the navigation stack. let onDeleted: () -> Void + /// The reader's own "New Document" toolbar button has no direct handle + /// on the sidebar row it belongs under — this tells the sidebar a + /// document exists now so it can pick it up. See + /// `CollectionDocumentsOutline.externalRefreshToken`. + let onDocumentCreated: () -> Void @State private var isShowingUnpublishConfirmation = false @State private var isShowingArchiveConfirmation = false @@ -40,13 +45,15 @@ struct DocumentReaderView: View { apiClient: OutlineAPIClient, document: OutlineDocument, onOpenChild: @escaping (OutlineDocument) -> Void, - onDeleted: @escaping () -> Void + onDeleted: @escaping () -> Void, + onDocumentCreated: @escaping () -> Void ) { self.apiClient = apiClient self.document = document _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) self.onOpenChild = onOpenChild self.onDeleted = onDeleted + self.onDocumentCreated = onDocumentCreated } var body: some View { @@ -213,6 +220,7 @@ struct DocumentReaderView: View { } .sheet(isPresented: $isShowingNewDocumentSheet) { NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in + onDocumentCreated() onOpenChild(child) } } diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift index ffc868f..1866af7 100644 --- a/Outpost/Features/Home/HomeView.swift +++ b/Outpost/Features/Home/HomeView.swift @@ -5,6 +5,10 @@ import OutlineKit struct HomeView: View { let apiClient: OutlineAPIClient let onOpenDocument: (OutlineDocument) -> Void + /// Home has no sidebar row of its own to reload directly — this tells + /// the sidebar a document exists now so it can pick it up. See + /// `CollectionDocumentsOutline.externalRefreshToken`. + let onDocumentCreated: () -> Void @State private var viewModel: HomeViewModel @State private var selectedTab: HomeTab = .recentlyViewed @@ -13,9 +17,10 @@ struct HomeView: View { private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)] private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] - init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) { + init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) { self.apiClient = apiClient self.onOpenDocument = onOpenDocument + self.onDocumentCreated = onDocumentCreated _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) } @@ -51,6 +56,7 @@ struct HomeView: View { } .sheet(isPresented: $isShowingNewDocumentSheet) { NewDocumentSheet(apiClient: apiClient) { document in + onDocumentCreated() onOpenDocument(document) } } From 1096967645dacf643181c1a7283a47d61c54bf67 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:52:34 +0100 Subject: [PATCH 6/7] feat(sidebar): add a Home row above the collections list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home was only reachable via the toolbar house icon — added a pinned row at the top of the sidebar (matching a collection row's style, highlighted when active) so it's a first-class nav target alongside collections rather than toolbar-only. --- .../Collections/CollectionsTreeView.swift | 33 +++++++++++++++++++ .../Collections/ContentView_macOS.swift | 2 ++ 2 files changed, 35 insertions(+) diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index 941801a..013455d 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -11,6 +11,8 @@ struct CollectionsTreeView: View { /// somewhere with no direct handle on the sidebar row it belongs /// under — see the identical parameter on `CollectionDocumentsOutline`. let externalRefreshToken: Int + let isShowingHome: Bool + let onSelectHome: () -> Void let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -19,6 +21,8 @@ struct CollectionsTreeView: View { selectedCollection: Binding, selectedDocumentID: String?, externalRefreshToken: Int, + isShowingHome: Bool, + onSelectHome: @escaping () -> Void, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSearchInCollection: @escaping (OutlineCollection) -> Void ) { @@ -26,6 +30,8 @@ struct CollectionsTreeView: View { _selectedCollection = selectedCollection self.selectedDocumentID = selectedDocumentID self.externalRefreshToken = externalRefreshToken + self.isShowingHome = isShowingHome + self.onSelectHome = onSelectHome self.onSelectDocument = onSelectDocument self.onSearchInCollection = onSearchInCollection } @@ -37,6 +43,7 @@ struct CollectionsTreeView: View { Task { await viewModel.load() } } } + homeRow content } .task { @@ -48,6 +55,32 @@ struct CollectionsTreeView: View { } } + /// Pinned above the collections list, not inside the scroll region — + /// Home isn't a collection, so it doesn't belong in `viewModel.collections` + /// or compete with them for scroll space. + private var homeRow: some View { + Button(action: onSelectHome) { + HStack(spacing: 6) { + Image(systemName: "house.fill") + .font(.callout) + .foregroundStyle(.secondary) + Text("Home") + .font(.body) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + .background( + isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.top, 4) + } + @ViewBuilder private var content: some View { Group { diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 89595fe..676f2e4 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -197,6 +197,8 @@ struct ContentView_macOS: View { selectedCollection: $selectedCollection, selectedDocumentID: documentPath.last?.id, externalRefreshToken: documentsChangedToken, + isShowingHome: isShowingHome, + onSelectHome: goHome, onSelectDocument: selectDocumentChain, onSearchInCollection: searchInCollection ) From 8ce0804c672847bddb63df06f96f29d1f01f63bc Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 17:19:07 +0100 Subject: [PATCH 7/7] fix(home): remove dead search icon, add staleness polling and retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CODEOWNERS | 32 +++++ .../Collections/ContentView_macOS.swift | 14 ++- Outpost/Features/Home/HomeView.swift | 45 +++++-- Outpost/Features/Home/HomeViewModel.swift | 118 ++++++++++++------ 4 files changed, 156 insertions(+), 53 deletions(-) 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: "|") + } }