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.
This commit is contained in:
2026-08-14 16:03:17 +01:00
parent 9991302683
commit c3083bf0c0
14 changed files with 612 additions and 64 deletions
@@ -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`.
@@ -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
@@ -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
}
}
@@ -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 = """
@@ -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))
@@ -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))
@@ -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
@@ -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")
}
@@ -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)
@@ -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
@@ -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
+10
View File
@@ -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 }
}
+122
View File
@@ -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
+94
View File
@@ -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
}
}