diff --git a/CODEOWNERS b/CODEOWNERS index f21e507..a24f154 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,37 @@ # Outpost Code Owners # These users are automatically requested for review on PRs. # Format: path @username +# +# Rules are evaluated in order, last match wins — so the specific paths +# below stay pinned to @psmattas even if `*` is ever opened up to other +# contributors/reviewers as the project grows. These are the +# supply-chain, governance, and CI-relevant files where an accidental or +# malicious change has outsized blast radius for a public repo. * @psmattas + +# Repo governance / legal — changes here affect every contributor. +/LICENSE @psmattas +/CODEOWNERS @psmattas +/CONTRIBUTING.md @psmattas +/SECURITY.md @psmattas +/SETUP.md @psmattas + +# CI, issue/PR automation, review requirements — tampering here can +# bypass the protections this very file is trying to set up. +/.gitea/ @psmattas + +# Dependency supply chain — a swapped or re-pinned package here can pull +# in arbitrary code at build time. +/OutlineKit/Package.swift @psmattas +/OutlineKit/Package.resolved @psmattas +/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @psmattas + +# Build/signing/versioning config and release tooling. +/Outpost.xcodeproj/project.pbxproj @psmattas +/scripts/ @psmattas + +# Project direction — architecture/scope decisions shouldn't drift via a +# drive-by PR. +/CLAUDE.md @psmattas +/docs/ARCHITECTURE.md @psmattas diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 3604545..90d34fb 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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`. diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 27a47bd..18090e6 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -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) } @@ -324,6 +332,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 diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift new file mode 100644 index 0000000..c9ae24a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentsListRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 759c98d..7ac7075 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -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 = """ diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index a93b840..593fe2f 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -15,9 +15,10 @@ struct CollectionDocumentsOutline: View { let refreshToken: Int /// Bumped from `ContentView_macOS` whenever a document is created from /// somewhere that has no direct handle on this row — the reader's - /// toolbar "New Document" button, specifically, which doesn't know which - /// (if any) sidebar row corresponds to the collection its new document - /// landed in, so every expanded row just reloads itself. + /// toolbar "New Document" button and Home's, specifically. Those can't + /// call `onDocumentsChanged()` the way a same-row sheet does, since they + /// don't know which (if any) sidebar row corresponds to where the new + /// document landed, so every expanded row just reloads itself. let externalRefreshToken: Int let selectedDocumentID: String? /// Full chain from root to the clicked document (inclusive) — lets the @@ -126,6 +127,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 { @@ -284,6 +286,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 @@ -339,7 +346,7 @@ private struct DocumentNodeRow: View { Button("Import Document…") {} .disabled(true) Button("New Document") { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } // Scoped to this collection (`collection.id`) — "Pin to Collection", // distinct from the reader toolbar's "Pin to Home" (collectionId: nil). @@ -446,26 +453,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)) diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 203e88e..515363b 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -24,6 +24,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 { @@ -95,6 +96,12 @@ struct CollectionTreeRow: View { } message: { Text(actionErrorMessage ?? "") } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in + documentsRefreshToken += 1 + Task { await onCollectionsChanged() } + } + } } @ViewBuilder @@ -106,7 +113,7 @@ struct CollectionTreeRow: View { Divider() Button("New Document") { - Task { await createDocument() } + isShowingNewDocumentSheet = true } Divider() @@ -151,18 +158,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)) diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index 71a6dd9..013455d 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -11,6 +11,8 @@ struct CollectionsTreeView: View { /// somewhere with no direct handle on the sidebar row it belongs /// under — see the identical parameter on `CollectionDocumentsOutline`. let externalRefreshToken: Int + let isShowingHome: Bool + let onSelectHome: () -> Void let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void @@ -19,6 +21,8 @@ struct CollectionsTreeView: View { selectedCollection: Binding, selectedDocumentID: String?, externalRefreshToken: Int, + isShowingHome: Bool, + onSelectHome: @escaping () -> Void, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSearchInCollection: @escaping (OutlineCollection) -> Void ) { @@ -26,6 +30,8 @@ struct CollectionsTreeView: View { _selectedCollection = selectedCollection self.selectedDocumentID = selectedDocumentID self.externalRefreshToken = externalRefreshToken + self.isShowingHome = isShowingHome + self.onSelectHome = onSelectHome self.onSelectDocument = onSelectDocument self.onSearchInCollection = onSearchInCollection } @@ -37,6 +43,7 @@ struct CollectionsTreeView: View { Task { await viewModel.load() } } } + homeRow content } .task { @@ -48,6 +55,32 @@ struct CollectionsTreeView: View { } } + /// Pinned above the collections list, not inside the scroll region — + /// Home isn't a collection, so it doesn't belong in `viewModel.collections` + /// or compete with them for scroll space. + private var homeRow: some View { + Button(action: onSelectHome) { + HStack(spacing: 6) { + Image(systemName: "house.fill") + .font(.callout) + .foregroundStyle(.secondary) + Text("Home") + .font(.body) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + .background( + isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.top, 4) + } + @ViewBuilder private var content: some View { Group { @@ -102,13 +135,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 diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 6410fee..3a7d250 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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. @@ -13,9 +17,9 @@ struct ContentView_macOS: View { @State private var isContextualSearchExpanded = false @FocusState private var isContextualSearchFocused: Bool /// Bumped whenever a document is created from somewhere with no direct - /// handle on the sidebar row it belongs under (the reader toolbar's - /// "New Document" button) — every expanded sidebar row reloads itself - /// in response. See `CollectionDocumentsOutline.externalRefreshToken`. + /// handle on the sidebar row it belongs under (the reader toolbar's and + /// Home's "New Document" buttons) — every expanded sidebar row reloads + /// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`. @State private var documentsChangedToken = 0 private var trimmedGlobalQuery: String { @@ -48,17 +52,47 @@ 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 } // Custom instead of `.searchable`: that modifier always renders a // full-width field, but this is meant to sit alongside the other // per-document toolbar buttons as a plain icon that only expands - // into a field once clicked. - ToolbarItem(placement: .primaryAction) { - contextualSearchField + // into a field once clicked. Hidden on the Home landing page + // itself — `contextualSearchQuery` is only ever read by + // `CollectionOverviewView`, so on Home it was a dead end: a + // user could click it, type, and nothing would happen. The + // sidebar's global search already covers "search everything." + if !(isShowingHome && documentPath.isEmpty) { + ToolbarItem(placement: .primaryAction) { + 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 = "" + contextualSearchQuery = "" + isContextualSearchExpanded = false + selectedCollection = nil + isShowingHome = true + replaceDocumentPath(with: []) } @ViewBuilder @@ -111,12 +145,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") @@ -135,6 +176,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 { @@ -159,6 +205,8 @@ struct ContentView_macOS: View { selectedCollection: $selectedCollection, selectedDocumentID: documentPath.last?.id, externalRefreshToken: documentsChangedToken, + isShowingHome: isShowingHome, + onSelectHome: goHome, onSelectDocument: selectDocumentChain, onSearchInCollection: searchInCollection ) @@ -211,6 +259,8 @@ struct ContentView_macOS: View { Group { if !trimmedGlobalQuery.isEmpty { GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) + } else if isShowingHome { + HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 }) } else if let selectedCollection { CollectionOverviewView( apiClient: apiClient, @@ -240,7 +290,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") } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index f5f80ff..3ba40aa 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -38,6 +38,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( @@ -123,7 +124,7 @@ struct DocumentReaderView: View { .disabled(viewModel.isSaving) Button { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } label: { Image(systemName: "doc.badge.plus") } @@ -217,6 +218,12 @@ struct DocumentReaderView: View { .sheet(isPresented: $isShowingShareSheet) { DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in + onDocumentCreated() + onOpenChild(child) + } + } } /// Every toggle-backed piece of state shown as a checkmark inside @@ -322,7 +329,7 @@ struct DocumentReaderView: View { Button("Import Document…") {} .disabled(true) Button("New Document") { - Task { await createChildDocument() } + isShowingNewDocumentSheet = true } Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } @@ -462,22 +469,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) - ) - onDocumentCreated() - 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) diff --git a/Outpost/Features/Collections/NewDocumentSheet.swift b/Outpost/Features/Collections/NewDocumentSheet.swift new file mode 100644 index 0000000..3a8d1a8 --- /dev/null +++ b/Outpost/Features/Collections/NewDocumentSheet.swift @@ -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 diff --git a/Outpost/Features/Home/DocumentCardView.swift b/Outpost/Features/Home/DocumentCardView.swift new file mode 100644 index 0000000..28692a1 --- /dev/null +++ b/Outpost/Features/Home/DocumentCardView.swift @@ -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 diff --git a/Outpost/Features/Home/HomeTab.swift b/Outpost/Features/Home/HomeTab.swift new file mode 100644 index 0000000..44faf3d --- /dev/null +++ b/Outpost/Features/Home/HomeTab.swift @@ -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 } +} diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift new file mode 100644 index 0000000..aca952d --- /dev/null +++ b/Outpost/Features/Home/HomeView.swift @@ -0,0 +1,190 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct HomeView: View { + let apiClient: OutlineAPIClient + let onOpenDocument: (OutlineDocument) -> Void + /// Home has no sidebar row of its own to reload directly — this tells + /// the sidebar a document exists now so it can pick it up. See + /// `CollectionDocumentsOutline.externalRefreshToken`. + let onDocumentCreated: () -> Void + + @State private var viewModel: HomeViewModel + @State private var selectedTab: HomeTab = .recentlyViewed + @State private var isShowingNewDocumentSheet = false + + private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)] + private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] + + init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) { + self.apiClient = apiClient + self.onOpenDocument = onOpenDocument + self.onDocumentCreated = onDocumentCreated + _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) + } + + private var isShowingPinnedSection: Bool { + viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.hasRemoteChanges { + RemoteChangesBanner { + Task { + await viewModel.loadPinned() + await viewModel.load(tab: selectedTab) + } + } + } + // The pinned section claims roughly the top half when it has + // anything to show (scrolling within itself if there are enough + // pinned documents to overflow that), and collapses away entirely + // when there's nothing pinned so the tabs get the full height. + GeometryReader { proxy in + VStack(spacing: 0) { + if isShowingPinnedSection { + pinnedSection + .frame(height: max(proxy.size.height / 2, 180)) + Divider() + } + tabSection + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + isShowingNewDocumentSheet = true + } label: { + Image(systemName: "doc.badge.plus") + } + .help("New Document") + } + } + .sheet(isPresented: $isShowingNewDocumentSheet) { + NewDocumentSheet(apiClient: apiClient) { document in + onDocumentCreated() + onOpenDocument(document) + } + } + .task { await viewModel.loadPinned() } + .task(id: selectedTab) { await viewModel.load(tab: selectedTab) } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(45)) + guard !Task.isCancelled else { break } + await viewModel.checkForRemoteChanges(tab: selectedTab) + } + } + } + + private var pinnedSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Pinned") + .font(.title3.weight(.semibold)) + .padding(.horizontal, 24) + .padding(.top, 20) + + if viewModel.isLoadingPinned { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: pinnedGridColumns, spacing: 8) { + ForEach(viewModel.pinnedDocuments) { document in + Button { + onOpenDocument(document) + } label: { + PinnedDocumentCard(document: document) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + } + } + } + + private var tabSection: some View { + VStack(alignment: .leading, spacing: 0) { + tabBar + + Divider() + + let documents = viewModel.documents(for: selectedTab) + + Group { + if viewModel.isLoadingTab && documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load(tab: selectedTab) } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Nothing to show here yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: tabGridColumns, spacing: 12) { + ForEach(documents) { document in + Button { + onOpenDocument(document) + } label: { + DocumentCardView(document: document) + } + .buttonStyle(.plain) + } + } + .padding(24) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the + // native `.pickerStyle(.segmented)` this started with. + private var tabBar: some View { + HStack(spacing: 4) { + Spacer(minLength: 0) + ForEach(HomeTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Text(tab.rawValue) + .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) + } +} +#endif diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift new file mode 100644 index 0000000..d1888ce --- /dev/null +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -0,0 +1,135 @@ +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? + /// Drives a "Refresh" banner rather than silently swapping content out + /// from under whoever's looking at it — see `CollectionsViewModel`'s + /// identical pattern. + var hasRemoteChanges = false + + 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 + } + } + + func loadPinned() async { + isLoadingPinned = true + defer { isLoadingPinned = false } + pinnedDocuments = await fetchPinned() + } + + func load(tab: HomeTab) async { + isLoadingTab = true + errorMessage = nil + defer { isLoadingTab = false } + do { + let documents = try await fetch(tab: tab) + set(documents, for: tab) + hasRemoteChanges = false + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.") + } + } + + /// Fetches fresh pinned docs and the current tab's documents to compare + /// against what's displayed, without replacing either. Bails silently + /// on a fetch failure rather than treating it as "changed" — a + /// transient network hiccup shouldn't pop the refresh banner. + func checkForRemoteChanges(tab: HomeTab) async { + async let freshPinned = fetchPinned() + guard let freshTab = try? await fetch(tab: tab) else { return } + let pinned = await freshPinned + if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) + || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { + hasRemoteChanges = true + } + } + + /// `pins.list` 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. + private func fetchPinned() async -> [OutlineDocument] { + guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { + return [] + } + var documents: [OutlineDocument] = [] + for pin in pins { + if let document = try? await apiClient.documentInfo(id: pin.documentId) { + documents.append(document) + } + } + return documents + } + + private func fetch(tab: HomeTab) async throws -> [OutlineDocument] { + switch tab { + case .recentlyViewed: + return try await apiClient.listViewedDocuments(offset: 0, limit: 25) + case .popular: + // `sort: "viewCount"` was a guess and the server rejected it + // outright ("sort: Invalid input") — sort is validated + // server-side against a fixed set, not free-form like the + // vendored spec's typing implies. Same conclusion as + // `CollectionTab.popular`: there's no real popularity + // ranking exposed via the REST API, so this falls back to + // the default list order rather than guessing again. + return try await apiClient.documentsList(DocumentsListRequest(limit: 25)) + case .recentlyUpdated: + return try await apiClient.documentsList( + DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25) + ) + case .createdByMe: + let userId = try await resolveCurrentUserID() + return try await apiClient.documentsList( + DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25) + ) + } + } + + private func set(_ documents: [OutlineDocument], for tab: HomeTab) { + switch tab { + case .recentlyViewed: recentlyViewed = documents + case .popular: popular = documents + case .recentlyUpdated: recentlyUpdated = documents + case .createdByMe: createdByMe = documents + } + } + + private func resolveCurrentUserID() async throws -> String { + if let currentUserID { return currentUserID } + let user = try await apiClient.currentUser() + currentUserID = user.id + return user.id + } + + private static func fingerprint(_ documents: [OutlineDocument]) -> String { + documents + .map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" } + .sorted() + .joined(separator: "|") + } +} diff --git a/Outpost/Features/Home/PinnedDocumentCard.swift b/Outpost/Features/Home/PinnedDocumentCard.swift new file mode 100644 index 0000000..04d19bc --- /dev/null +++ b/Outpost/Features/Home/PinnedDocumentCard.swift @@ -0,0 +1,46 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Deliberately distinct from `DocumentCardView` — the pinned section is for +/// a quick scan of a small curated set, not browsing, so this is a dense +/// single-line row rather than a tall card, with an explicit pin glyph so +/// it doesn't read the same as the tab grids below it. +struct PinnedDocumentCard: View { + @Environment(StarStore.self) private var starStore + let document: OutlineDocument + + var body: some View { + HStack(spacing: 10) { + if let emoji = document.emoji { + Text(emoji) + .font(.title3) + } else { + Image(systemName: "doc.text") + .font(.body) + .foregroundStyle(.secondary) + } + + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.callout.weight(.medium)) + .lineLimit(1) + + Spacer(minLength: 0) + + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + } +} +#endif