Merge pull request 'Add Home page, universal New Document dialog, and fix Pin/toggle/CODEOWNERS gaps found in live testing' (#4) from feature/home-page into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+32
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<OutlineCollection?>,
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: "|")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user