Files
Outpost/Outpost/Features/Collections/CollectionTreeRow.swift
T
Puranjay Savar Mattas c2b41ca960 fix(sidebar): auto-refresh after creating a doc from Home or the reader toolbar
The reader's inline "New Document" button and Home's New Document sheet
only navigated to the new document — neither had a handle on the
sidebar row it landed under, so the tree stayed stale until the
periodic remote-changes poll (45s) surfaced the "reload" banner.

New Document flows that already have a direct handle on their own
sidebar row (right-click a collection, right-click a document) already
refreshed correctly and are untouched.

Threads a documentsChangedToken from ContentView_macOS down through
CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline
as externalRefreshToken; every expanded row reloads itself when it
bumps, since neither Home nor the reader knows which row (if any)
corresponds to where the new document landed.
2026-08-14 16:46:40 +01:00

188 lines
6.1 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
@MainActor
struct CollectionTreeRow: View {
@Environment(StarStore.self) private var starStore
let apiClient: OutlineAPIClient
let collection: OutlineCollection
let isExpanded: Bool
let isSelected: Bool
let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
let onCollectionsChanged: () async -> Void
@State private var sortOption: SidebarSortOption = .manual
@State private var documentsRefreshToken = 0
@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 {
VStack(alignment: .leading, spacing: 0) {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
CollectionRowView(collection: collection)
Spacer(minLength: 0)
if starStore.isStarred(collectionId: collection.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
if isExpanded {
CollectionDocumentsOutline(
apiClient: apiClient,
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
.padding(.leading, 18)
}
}
.alert("Rename Collection", isPresented: $isShowingRenameAlert) {
TextField("Name", text: $renameText)
Button("Cancel", role: .cancel) {}
Button("Rename") {
Task { await rename() }
}
}
.confirmationDialog(
"Delete \"\(collection.name)\"?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
Task { await delete() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This deletes the collection and all of its documents. This can't be undone.")
}
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
documentsRefreshToken += 1
Task { await onCollectionsChanged() }
}
}
}
@ViewBuilder
private var contextMenuContent: some View {
Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") {
Task { await star() }
}
Divider()
Button("New Document") {
isShowingNewDocumentSheet = true
}
Divider()
Button("Rename…") {
renameText = collection.name
isShowingRenameAlert = true
}
Divider()
Menu("Sort in Sidebar") {
Picker("Sort", selection: $sortOption) {
ForEach(SidebarSortOption.allCases) { option in
Text(option.label).tag(option)
}
}
}
Divider()
Button("Export…") {
Task { await export() }
}
Button("Search in Collection") {
onSearchInCollection(collection)
}
Divider()
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
}
private func star() async {
do {
try await starStore.toggleCollection(collection.id, apiClient: apiClient)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.")
}
}
private func rename() async {
do {
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't rename this collection.")
}
}
private func export() async {
do {
_ = try await apiClient.exportCollection(ExportCollectionRequest(id: collection.id))
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't start the export.")
}
}
private func delete() async {
do {
try await apiClient.deleteCollection(id: collection.id)
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this collection.")
}
}
}
#endif