feat(collections): add collections and documents browsing

macOS: expandable/collapsible sidebar tree (collections.list, with
nested documents.list per expanded collection) driving a detail pane.
iOS: flat collection list pushing to a document list. Both share row
views and per-platform ContentView selectors, matching the AuthView
pattern.
This commit is contained in:
2026-08-14 00:24:07 +01:00
parent 43a10a6ec3
commit 6dd632bb97
13 changed files with 613 additions and 0 deletions
@@ -0,0 +1,58 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Flat document list nested under an expanded collection in the sidebar tree.
/// Documents can themselves have children (`parentDocumentId`) in Outline not
/// represented here yet, this is a first pass at the collection-level expansion.
struct CollectionDocumentsOutline: View {
@State private var viewModel: DocumentsViewModel
let onSelectDocument: (OutlineDocument) -> Void
init(apiClient: OutlineAPIClient, collection: OutlineCollection, onSelectDocument: @escaping (OutlineDocument) -> Void) {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.onSelectDocument = onSelectDocument
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.documents.isEmpty {
ProgressView()
.controlSize(.small)
.padding(.vertical, 6)
} else if viewModel.documents.isEmpty {
Text("No documents")
.font(.callout)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
} else {
ForEach(viewModel.documents) { document in
Button {
onSelectDocument(document)
} label: {
HStack(spacing: 8) {
if let emoji = document.emoji {
Text(emoji)
.font(.body)
} else {
Image(systemName: "doc.text")
.font(.callout)
.foregroundStyle(.secondary)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.body)
.lineLimit(1)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 6)
}
}
}
.task { await viewModel.load() }
}
}
#endif
@@ -0,0 +1,42 @@
import SwiftUI
import OutlineKit
struct CollectionListContent: View {
var viewModel: CollectionsViewModel
let onSelect: (OutlineCollection) -> Void
var body: some View {
Group {
if viewModel.isLoading && viewModel.collections.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load() }
}
}
} else if viewModel.collections.isEmpty {
ContentUnavailableView(
"No Collections",
systemImage: "folder",
description: Text("Collections you have access to will appear here.")
)
} else {
List(viewModel.collections) { collection in
Button {
onSelect(collection)
} label: {
CollectionRowView(collection: collection)
}
.buttonStyle(.plain)
}
}
}
.task { await viewModel.load() }
}
}
@@ -0,0 +1,41 @@
import SwiftUI
import OutlineKit
struct CollectionRowView: View {
let collection: OutlineCollection
var body: some View {
Label {
Text(collection.name)
} icon: {
if let emoji = collection.emojiIcon {
Text(emoji)
} else {
Image(systemName: "folder.fill")
.foregroundStyle(tintColor)
}
}
}
private var tintColor: Color {
collection.color.flatMap { Color(hex: $0) } ?? .accentColor
}
}
private extension OutlineCollection {
/// Outline's `icon` field holds either a literal emoji (when a user picked one)
/// or an internal icon-set name (e.g. from their built-in icon picker) only
/// the former is renderable here without a name-to-glyph mapping table.
var emojiIcon: String? {
// `isEmojiPresentation` misses symbol-class emoji that default to text
// presentation (e.g. some picked without a variation selector) `isEmoji`
// is the broader, correct check for "this scalar can be an emoji at all".
// Plain ASCII digits/`#`/`*` also report `isEmoji == true` (they're valid
// keycap-sequence bases), so exclude the ASCII range to avoid treating a
// literal digit or punctuation icon name as an emoji.
guard let icon, icon.unicodeScalars.contains(where: { $0.properties.isEmoji && $0.value > 0x7F }) else {
return nil
}
return icon
}
}
@@ -0,0 +1,49 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionTreeRow: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
let isExpanded: Bool
let isSelected: Bool
let onToggle: () -> Void
let onSelectDocument: (OutlineDocument) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
CollectionRowView(collection: collection)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: isExpanded)
if isExpanded {
CollectionDocumentsOutline(
apiClient: apiClient,
collection: collection,
onSelectDocument: onSelectDocument
)
.padding(.leading, 18)
}
}
}
}
#endif
@@ -0,0 +1,78 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionsTreeView: View {
@State private var viewModel: CollectionsViewModel
@Binding private var selectedCollection: OutlineCollection?
@State private var expandedCollectionIDs: Set<String> = []
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
init(
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection
self.onSelectDocument = onSelectDocument
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.collections.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load() }
}
}
} else if viewModel.collections.isEmpty {
ContentUnavailableView(
"No Collections",
systemImage: "folder",
description: Text("Collections you have access to will appear here.")
)
} else {
List {
ForEach(viewModel.collections) { collection in
CollectionTreeRow(
apiClient: viewModel.apiClient,
collection: collection,
isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id,
onToggle: { toggle(collection) },
onSelectDocument: { document in onSelectDocument(collection, document) }
)
}
}
}
}
.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
}
}
}
private func toggle(_ collection: OutlineCollection) {
selectedCollection = collection
if expandedCollectionIDs.contains(collection.id) {
expandedCollectionIDs.remove(collection.id)
} else {
expandedCollectionIDs.insert(collection.id)
}
}
}
#endif
@@ -0,0 +1,29 @@
import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class CollectionsViewModel {
private(set) var collections: [OutlineCollection] = []
var isLoading = false
var errorMessage: String?
let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = "Couldn't load collections. Check your connection and try again."
}
}
}
@@ -0,0 +1,46 @@
#if os(iOS)
import SwiftUI
import OutlineKit
struct ContentView_iOS: View {
@Environment(SessionStore.self) private var session
var body: some View {
NavigationStack {
Group {
if let apiClient = session.apiClient {
CollectionsScreen(apiClient: apiClient)
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
.navigationTitle(session.teamName ?? "Outpost")
.toolbar {
ToolbarItem(placement: .navigation) {
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
}
}
}
}
}
private struct CollectionsScreen: View {
@State private var viewModel: CollectionsViewModel
@State private var pushedCollection: OutlineCollection?
private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
}
var body: some View {
CollectionListContent(viewModel: viewModel) { collection in
pushedCollection = collection
}
.navigationDestination(item: $pushedCollection) { collection in
DocumentsPane(apiClient: apiClient, collection: collection)
}
}
}
#endif
@@ -0,0 +1,149 @@
#if os(macOS)
import SwiftUI
import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@State private var selectedCollection: OutlineCollection?
@State private var documentPath: [OutlineDocument] = []
@State private var globalSearchQuery = ""
@State private var contextualSearchQuery = ""
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
sidebar
AccountFooter()
}
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
detail
}
.navigationTitle(session.teamName ?? "Outpost")
.toolbar {
// Per HIG: "sidebar-toggle/back controls appear at the far leading
// edge, followed by the view title" this is that title, not
// centered. It doesn't collide with the back button because they're
// mutually exclusive: whenever a document's pushed (back button
// visible), this shows the document's own title instead of falling
// back to the workspace badge.
ToolbarItem(placement: .navigation) {
leadingToolbarContent
}
}
// Attached at this level (not inside the detail's NavigationStack) so
// it reliably renders in the shared window toolbar instead of a
// per-screen one that can get lost when nested more deeply.
.searchable(
text: $contextualSearchQuery,
placement: .toolbar,
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
)
}
@ViewBuilder
private var leadingToolbarContent: some View {
Group {
if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
Text("Search")
}
} else if let currentDocument = documentPath.last, let selectedCollection {
// Back button (system-provided) collection document, so the
// hierarchy reads left to right instead of just the leaf title.
HStack(spacing: 6) {
CollectionRowView(collection: selectedCollection)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
if let emoji = currentDocument.emoji {
Text(emoji)
} else {
Image(systemName: "doc.text")
}
Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title)
.lineLimit(1)
}
} else if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
HStack(spacing: 8) {
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
Text(session.teamName ?? "Outpost")
.lineLimit(1)
}
}
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
@ViewBuilder
private var sidebar: some View {
if let apiClient = session.apiClient {
CollectionsTreeView(
apiClient: apiClient,
selectedCollection: $selectedCollection,
onSelectDocument: selectDocument
)
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
/// `documentPath = [document]` (replacing a same-size array in one step)
/// unreliably updates `NavigationStack` on macOS the stack can silently
/// keep showing the previous document. Popping to root and pushing again
/// as two separate state updates (so SwiftUI processes them as two render
/// passes) is the reliable version of the same operation.
private func selectDocument(_ collection: OutlineCollection, _ document: OutlineDocument) {
selectedCollection = collection
documentPath = []
Task { @MainActor in
documentPath = [document]
}
}
@ViewBuilder
private var detail: some View {
if let apiClient = session.apiClient {
// A fresh NavigationStack per collection (or when entering/leaving
// search), so switching either also clears any pushed document.
NavigationStack(path: $documentPath) {
Group {
if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery)
} else if let selectedCollection {
CollectionOverviewView(
apiClient: apiClient,
collection: selectedCollection,
searchQuery: $contextualSearchQuery
)
} else {
ContentUnavailableView(
"No Collection Selected",
systemImage: "sidebar.left",
description: Text("Choose a collection to see its documents.")
)
}
}
.navigationDestination(for: OutlineDocument.self) { document in
DocumentReaderView(apiClient: apiClient, document: document)
}
}
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
}
#endif
@@ -0,0 +1,37 @@
import SwiftUI
import OutlineKit
struct DocumentListContent: View {
var viewModel: DocumentsViewModel
var body: some View {
Group {
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 \(viewModel.collection.name) will appear here.")
)
} else {
List(viewModel.documents) { document in
DocumentRowView(document: document)
}
}
}
.navigationTitle(viewModel.collection.name)
.task { await viewModel.load() }
}
}
@@ -0,0 +1,22 @@
import SwiftUI
import OutlineKit
struct DocumentRowView: View {
let document: OutlineDocument
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
if let emoji = document.emoji {
Text(emoji)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.body)
.lineLimit(1)
}
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,17 @@
import SwiftUI
import OutlineKit
/// Holds `DocumentsViewModel` in `@State` so callers that construct this from a
/// computed/closure context (`NavigationSplitView.detail`, `navigationDestination`)
/// don't rebuild and reset it on every unrelated re-render.
struct DocumentsPane: View {
@State private var viewModel: DocumentsViewModel
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
}
var body: some View {
DocumentListContent(viewModel: viewModel)
}
}
@@ -0,0 +1,31 @@
import Foundation
import Observation
import OutlineKit
@MainActor
@Observable
final class DocumentsViewModel {
private(set) var documents: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
let collection: OutlineCollection
private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
self.apiClient = apiClient
self.collection = collection
}
func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100)
} catch {
errorMessage = "Couldn't load documents. Check your connection and try again."
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
extension Color {
init?(hex: String) {
var sanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
sanitized.removeAll { $0 == "#" }
guard sanitized.count == 6, let value = UInt32(sanitized, radix: 16) else { return nil }
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}