Merge branch 'main' into feature/home-page

# Conflicts:
#	Outpost/Features/Collections/CollectionDocumentsOutline.swift
#	Outpost/Features/Collections/CollectionsTreeView.swift
#	Outpost/Features/Collections/ContentView_macOS.swift
#	Outpost/Features/Collections/DocumentReaderView.swift
This commit is contained in:
2026-08-14 17:10:12 +01:00
2 changed files with 51 additions and 14 deletions
@@ -299,15 +299,15 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
private struct PinsListPayload: Decodable { private struct PinsListPayload: Decodable {
let pins: [OutlinePin] let pins: [OutlinePin]
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
private struct StarIDParams: Encodable { private struct StarIDParams: Encodable {
let id: String let id: String
} }
@@ -9,6 +9,7 @@ import OutlineKit
/// client-side rather than `collections.documents`. /// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View { struct CollectionDocumentsOutline: View {
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption let sortOption: SidebarSortOption
let refreshToken: Int let refreshToken: Int
@@ -24,6 +25,11 @@ struct CollectionDocumentsOutline: View {
/// toolbar render the real hierarchy instead of just the leaf title. /// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
/// Loaded once per collection (`pins.list` is collection-scoped) rather
/// than per-row a per-row `pins.list`/lookup would be an N+1 call for
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] { private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
} }
@@ -38,6 +44,7 @@ struct CollectionDocumentsOutline: View {
onSelectDocument: @escaping ([OutlineDocument]) -> Void onSelectDocument: @escaping ([OutlineDocument]) -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption self.sortOption = sortOption
self.refreshToken = refreshToken self.refreshToken = refreshToken
@@ -65,8 +72,10 @@ struct CollectionDocumentsOutline: View {
depth: 0, depth: 0,
ancestors: [], ancestors: [],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, onSelectDocument: onSelectDocument,
onDocumentsChanged: { await viewModel.load() } onDocumentsChanged: { await viewModel.load() },
onPinsChanged: { await loadPins() }
) )
} }
} }
@@ -74,7 +83,15 @@ struct CollectionDocumentsOutline: View {
// Combined into one identity rather than two separate `.task(id:)` // Combined into one identity rather than two separate `.task(id:)`
// modifiers each of those fires once unconditionally on first // modifiers each of those fires once unconditionally on first
// appear, so two of them would double the initial load. // appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() } .task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
}
}
private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
} }
} }
@@ -93,8 +110,10 @@ private struct DocumentNodeRow: View {
/// Chain from root down to (not including) this node. /// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument] let ancestors: [OutlineDocument]
let selectedDocumentID: String? let selectedDocumentID: String?
let pinsByDocumentID: [String: OutlinePin]
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onDocumentsChanged: () async -> Void let onDocumentsChanged: () async -> Void
let onPinsChanged: () async -> Void
@State private var isExpanded = false @State private var isExpanded = false
@@ -185,8 +204,10 @@ private struct DocumentNodeRow: View {
depth: depth + 1, depth: depth + 1,
ancestors: ancestors + [node.document], ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
pinsByDocumentID: pinsByDocumentID,
onSelectDocument: onSelectDocument, onSelectDocument: onSelectDocument,
onDocumentsChanged: onDocumentsChanged onDocumentsChanged: onDocumentsChanged,
onPinsChanged: onPinsChanged
) )
} }
} }
@@ -277,7 +298,11 @@ private struct DocumentNodeRow: View {
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
// No `subscriptions.*` endpoint in the API nothing to back this with. // subscriptions.* does exist and works (confirmed against a live
// server via the reader's menu) not shown here because
// subscriptions.list is per-document, so reflecting accurate
// per-row state for every document in the tree would mean an N+1
// call storm. Use the reader's menu instead.
Button("Unsubscribe") {} Button("Unsubscribe") {}
.disabled(true) .disabled(true)
@@ -323,12 +348,11 @@ private struct DocumentNodeRow: View {
Button("New Document") { Button("New Document") {
isShowingNewDocumentSheet = true isShowingNewDocumentSheet = true
} }
// `pins.*` is real and works (see `OutlinePin`), but "Pin to // Scoped to this collection (`collection.id`) "Pin to Collection",
// Collection" (non-nil `collectionId`) is distinct from the reader // distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
// toolbar's "Pin to Home" and needs its own per-row state/N+1 Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
// consideration for a whole tree not wired here yet. Task { await togglePin() }
Button("Pin") {} }
.disabled(true)
Divider() Divider()
@@ -372,6 +396,19 @@ private struct DocumentNodeRow: View {
} }
} }
private func togglePin() async {
do {
if let pin = pinsByDocumentID[node.document.id] {
try await apiClient.deletePin(id: pin.id)
} else {
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
}
await onPinsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
}
}
private func rename() async { private func rename() async {
do { do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))