From 6dd632bb97fcd0bf8f45bab49f4c8592c80b5268 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:24:07 +0100 Subject: [PATCH] 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. --- .../CollectionDocumentsOutline.swift | 58 +++++++ .../Collections/CollectionListContent.swift | 42 +++++ .../Collections/CollectionRowView.swift | 41 +++++ .../Collections/CollectionTreeRow.swift | 49 ++++++ .../Collections/CollectionsTreeView.swift | 78 +++++++++ .../Collections/CollectionsViewModel.swift | 29 ++++ .../Collections/ContentView_iOS.swift | 46 ++++++ .../Collections/ContentView_macOS.swift | 149 ++++++++++++++++++ .../Collections/DocumentListContent.swift | 37 +++++ .../Collections/DocumentRowView.swift | 22 +++ .../Features/Collections/DocumentsPane.swift | 17 ++ .../Collections/DocumentsViewModel.swift | 31 ++++ Outpost/Support/Color+Hex.swift | 14 ++ 13 files changed, 613 insertions(+) create mode 100644 Outpost/Features/Collections/CollectionDocumentsOutline.swift create mode 100644 Outpost/Features/Collections/CollectionListContent.swift create mode 100644 Outpost/Features/Collections/CollectionRowView.swift create mode 100644 Outpost/Features/Collections/CollectionTreeRow.swift create mode 100644 Outpost/Features/Collections/CollectionsTreeView.swift create mode 100644 Outpost/Features/Collections/CollectionsViewModel.swift create mode 100644 Outpost/Features/Collections/ContentView_iOS.swift create mode 100644 Outpost/Features/Collections/ContentView_macOS.swift create mode 100644 Outpost/Features/Collections/DocumentListContent.swift create mode 100644 Outpost/Features/Collections/DocumentRowView.swift create mode 100644 Outpost/Features/Collections/DocumentsPane.swift create mode 100644 Outpost/Features/Collections/DocumentsViewModel.swift create mode 100644 Outpost/Support/Color+Hex.swift diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift new file mode 100644 index 0000000..7524a92 --- /dev/null +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionListContent.swift b/Outpost/Features/Collections/CollectionListContent.swift new file mode 100644 index 0000000..c2399ab --- /dev/null +++ b/Outpost/Features/Collections/CollectionListContent.swift @@ -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() } + } +} diff --git a/Outpost/Features/Collections/CollectionRowView.swift b/Outpost/Features/Collections/CollectionRowView.swift new file mode 100644 index 0000000..ee5b693 --- /dev/null +++ b/Outpost/Features/Collections/CollectionRowView.swift @@ -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 + } +} diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift new file mode 100644 index 0000000..fc3ebcf --- /dev/null +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift new file mode 100644 index 0000000..e8121a7 --- /dev/null +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -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 = [] + let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void + + init( + apiClient: OutlineAPIClient, + selectedCollection: Binding, + 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 diff --git a/Outpost/Features/Collections/CollectionsViewModel.swift b/Outpost/Features/Collections/CollectionsViewModel.swift new file mode 100644 index 0000000..f450ff5 --- /dev/null +++ b/Outpost/Features/Collections/CollectionsViewModel.swift @@ -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." + } + } +} diff --git a/Outpost/Features/Collections/ContentView_iOS.swift b/Outpost/Features/Collections/ContentView_iOS.swift new file mode 100644 index 0000000..ced40d3 --- /dev/null +++ b/Outpost/Features/Collections/ContentView_iOS.swift @@ -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 diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift new file mode 100644 index 0000000..8d7b988 --- /dev/null +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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 diff --git a/Outpost/Features/Collections/DocumentListContent.swift b/Outpost/Features/Collections/DocumentListContent.swift new file mode 100644 index 0000000..3ee4c9d --- /dev/null +++ b/Outpost/Features/Collections/DocumentListContent.swift @@ -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() } + } +} diff --git a/Outpost/Features/Collections/DocumentRowView.swift b/Outpost/Features/Collections/DocumentRowView.swift new file mode 100644 index 0000000..39d8eb2 --- /dev/null +++ b/Outpost/Features/Collections/DocumentRowView.swift @@ -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) + } + } +} diff --git a/Outpost/Features/Collections/DocumentsPane.swift b/Outpost/Features/Collections/DocumentsPane.swift new file mode 100644 index 0000000..7bc2b0e --- /dev/null +++ b/Outpost/Features/Collections/DocumentsPane.swift @@ -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) + } +} diff --git a/Outpost/Features/Collections/DocumentsViewModel.swift b/Outpost/Features/Collections/DocumentsViewModel.swift new file mode 100644 index 0000000..de21577 --- /dev/null +++ b/Outpost/Features/Collections/DocumentsViewModel.swift @@ -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." + } + } +} diff --git a/Outpost/Support/Color+Hex.swift b/Outpost/Support/Color+Hex.swift new file mode 100644 index 0000000..be81b49 --- /dev/null +++ b/Outpost/Support/Color+Hex.swift @@ -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 + ) + } +}