Three parity gaps found reviewing Home against CollectionOverviewView: - The toolbar's contextual search icon rendered on Home but contextualSearchQuery is only ever read by CollectionOverviewView — clicking it and typing did nothing. Hidden specifically on the Home landing page per explicit request (sidebar's global search already covers this); goHome() also clears the stale query/expanded state so it can't leak back in when returning to a collection. - Home had no periodic remote-changes check, unlike CollectionsTreeView and CollectionOverviewView. Added the same 45s-poll + banner pattern, fingerprinting pinned docs and the current tab's docs against fresh fetches; bails silently on a fetch failure instead of false-positive triggering the banner. - Tab load errors showed a message but no way to retry short of switching tabs and back. Added a Retry button matching the collection document list's. Also hardens CODEOWNERS ahead of going public: kept `* @psmattas` as the catch-all but pinned supply-chain/governance/CI paths (Package manifests, .gitea/, xcodeproj build settings, scripts/, LICENSE, CONTRIBUTING/SECURITY/SETUP, CLAUDE.md, docs/ARCHITECTURE.md) explicitly to @psmattas so they stay owner-gated even if `*` opens up to other contributors later.
191 lines
7.3 KiB
Swift
191 lines
7.3 KiB
Swift
#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
|