feat(collections): add tabbed overview and document reader (macOS)

CollectionOverviewView mirrors Outline's own collection tabs (Overview,
Documents, Popular, sort variants), with the Overview tab rendering the
collection's description through MarkdownEngine. DocumentReaderView
opens a tapped document read-only via the same engine, re-fetching full
content through documents.info rather than trusting the list payload.
This commit is contained in:
2026-08-14 00:24:15 +01:00
parent 6dd632bb97
commit 84b4704f89
5 changed files with 315 additions and 0 deletions
@@ -0,0 +1,29 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses
/// `NativeTextViewWrapper`'s own `isEditable: false`, not `.disabled(true)`:
/// the latter blocks ALL interaction including link clicks, since
/// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View {
@State private var markdown: String
init(collection: OutlineCollection) {
_markdown = State(initialValue: collection.description ?? "")
}
var body: some View {
ScrollView {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(heightBehavior: .fitsContent),
isEditable: false
)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
#endif
@@ -0,0 +1,153 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
// Contextual search "search what you're looking at" scoped to this
// collection's documents. The field itself lives on the parent
// NavigationStack (so it survives pushing into a document); this owns
// only the resulting search state.
@State private var searchViewModel: DocumentTitleSearchViewModel
@Binding var searchQuery: String
private var trimmedSearchQuery: String {
searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
init(apiClient: OutlineAPIClient, collection: OutlineCollection, searchQuery: Binding<String>) {
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
_searchQuery = searchQuery
}
private var sortedDocuments: [OutlineDocument] {
selectedTab.sorted(viewModel.documents)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// No in-content header the collection's icon/title live in the
// window toolbar now (via `ContentView_macOS`), so this doesn't
// duplicate it directly below.
if trimmedSearchQuery.isEmpty {
tabBar
}
Divider()
// All branches get the same frame so switching tabs/search doesn't
// visibly resize/jump the content area to its own ideal size.
Group {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
} else {
documentList
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// No `.navigationTitle` here it renders its own native title bubble,
// duplicating the leading toolbar item `ContentView_macOS` already
// shows (which also carries the collection > document hierarchy).
.task { await viewModel.load() }
.task(id: trimmedSearchQuery) {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
await searchViewModel.search(query: trimmedSearchQuery)
}
}
// Spans the full window width, centered, directly under the toolbar
// not a scrolling, left-aligned row anymore.
private var tabBar: some View {
HStack(spacing: 4) {
Spacer(minLength: 0)
ForEach(CollectionTab.allCases) { tab in
Button {
selectedTab = tab
} label: {
Text(tab.label)
.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)
}
@ViewBuilder
private var documentList: some View {
if viewModel.isLoading && viewModel.documents.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load() }
}
}
} else if viewModel.documents.isEmpty {
ContentUnavailableView(
"No Documents",
systemImage: "doc.text",
description: Text("Documents in \(collection.name) will appear here.")
)
} else {
List(sortedDocuments) { document in
NavigationLink(value: document) {
DocumentRowView(document: document)
}
}
}
}
@ViewBuilder
private var searchResultsList: some View {
if searchViewModel.isSearching && searchViewModel.results.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = searchViewModel.errorMessage {
ContentUnavailableView {
Label("Search Failed", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else if searchViewModel.results.isEmpty {
ContentUnavailableView.search(text: trimmedSearchQuery)
} else {
List(searchViewModel.results) { result in
NavigationLink(value: result.document) {
VStack(alignment: .leading, spacing: 4) {
DocumentRowView(document: result.document)
Text(result.context)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.vertical, 2)
}
}
}
}
}
#endif
@@ -0,0 +1,45 @@
import Foundation
import OutlineKit
/// Mirrors Outline's own top tab bar on a collection's page.
enum CollectionTab: String, CaseIterable, Identifiable {
case overview
case documents
case popular
case recentlyUpdated
case recentlyPublished
case leastRecentlyUpdated
case alphabetical
var id: String { rawValue }
var label: String {
switch self {
case .overview: return "Overview"
case .documents: return "Documents"
case .popular: return "Popular"
case .recentlyUpdated: return "Recently Updated"
case .recentlyPublished: return "Recently Published"
case .leastRecentlyUpdated: return "Least Recently Updated"
case .alphabetical: return "A-Z"
}
}
/// `.popular` has no real ranking signal yet it would need view-count /
/// analytics data the REST layer doesn't expose so it falls back to the
/// same order as `.documents` rather than fabricating a ranking.
func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] {
switch self {
case .overview, .documents, .popular:
return documents
case .recentlyUpdated:
return documents.sorted { $0.updatedAt > $1.updatedAt }
case .recentlyPublished:
return documents.sorted { ($0.publishedAt ?? $0.createdAt) > ($1.publishedAt ?? $1.createdAt) }
case .leastRecentlyUpdated:
return documents.sorted { $0.updatedAt < $1.updatedAt }
case .alphabetical:
return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
}
}
@@ -0,0 +1,47 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import OutlineKit
/// Read-only (`isEditable: false`), matching the Overview tab document editing
/// isn't wired up yet, but link clicks and text selection still work.
struct DocumentReaderView: View {
@State private var viewModel: DocumentReaderViewModel
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// No in-content header the document's icon/title live in the
// window toolbar now (via `ContentView_macOS`).
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
isEditable: false
)
}
}
.padding()
}
// No `.navigationTitle` here either same reason as CollectionOverviewView.
.task { await viewModel.loadFullContent() }
}
}
#endif
@@ -0,0 +1,41 @@
import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class DocumentReaderViewModel {
var title: String
var emoji: String?
var text: String
var isLoading = false
var errorMessage: String?
private let apiClient: OutlineAPIClient
private let documentId: String
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.documentId = document.id
self.title = document.title
self.emoji = document.emoji
self.text = document.text
}
/// The list endpoint's copy of a document isn't guaranteed to be the full,
/// current body always re-fetch via `documents.info` when actually opened.
func loadFullContent() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let full = try await apiClient.documentInfo(id: documentId)
title = full.title
emoji = full.emoji
text = full.text
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
}
}