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.
This commit is contained in:
2026-08-14 16:46:40 +01:00
parent b45d72238c
commit c2b41ca960
6 changed files with 48 additions and 5 deletions
@@ -12,6 +12,13 @@ struct CollectionDocumentsOutline: View {
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption let sortOption: SidebarSortOption
let refreshToken: Int 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 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? let selectedDocumentID: String?
/// Full chain from root to the clicked document (inclusive) lets the /// Full chain from root to the clicked document (inclusive) lets the
/// toolbar render the real hierarchy instead of just the leaf title. /// toolbar render the real hierarchy instead of just the leaf title.
@@ -26,6 +33,7 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection, collection: OutlineCollection,
sortOption: SidebarSortOption, sortOption: SidebarSortOption,
refreshToken: Int, refreshToken: Int,
externalRefreshToken: Int,
selectedDocumentID: String?, selectedDocumentID: String?,
onSelectDocument: @escaping ([OutlineDocument]) -> Void onSelectDocument: @escaping ([OutlineDocument]) -> Void
) { ) {
@@ -33,6 +41,7 @@ struct CollectionDocumentsOutline: View {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption self.sortOption = sortOption
self.refreshToken = refreshToken self.refreshToken = refreshToken
self.externalRefreshToken = externalRefreshToken
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
} }
@@ -62,7 +71,10 @@ struct CollectionDocumentsOutline: View {
} }
} }
} }
.task(id: refreshToken) { await viewModel.load() } // Combined into one identity rather than two separate `.task(id:)`
// modifiers each of those fires once unconditionally on first
// appear, so two of them would double the initial load.
.task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() }
} }
} }
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
let isExpanded: Bool let isExpanded: Bool
let isSelected: Bool let isSelected: Bool
let selectedDocumentID: String? let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -63,6 +65,7 @@ struct CollectionTreeRow: View {
collection: collection, collection: collection,
sortOption: sortOption, sortOption: sortOption,
refreshToken: documentsRefreshToken, refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument onSelectDocument: onSelectDocument
) )
@@ -7,6 +7,10 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection? @Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String? let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = [] @State private var expandedCollectionIDs: Set<String> = []
/// Bumped from `ContentView_macOS` whenever a document is created
/// somewhere with no direct handle on the sidebar row it belongs
/// under see the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -14,12 +18,14 @@ struct CollectionsTreeView: View {
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>, selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?, selectedDocumentID: String?,
externalRefreshToken: Int,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void onSearchInCollection: @escaping (OutlineCollection) -> Void
) { ) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection _selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection self.onSearchInCollection = onSearchInCollection
} }
@@ -81,6 +87,7 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id), isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id, isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID, selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) }, onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) }, onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection, onSearchInCollection: onSearchInCollection,
@@ -16,6 +16,11 @@ struct ContentView_macOS: View {
@State private var contextualSearchQuery = "" @State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false @State private var isContextualSearchExpanded = false
@FocusState private var isContextualSearchFocused: Bool @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 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 { private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -191,6 +196,7 @@ struct ContentView_macOS: View {
apiClient: apiClient, apiClient: apiClient,
selectedCollection: $selectedCollection, selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id, selectedDocumentID: documentPath.last?.id,
externalRefreshToken: documentsChangedToken,
onSelectDocument: selectDocumentChain, onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection onSearchInCollection: searchInCollection
) )
@@ -244,7 +250,7 @@ struct ContentView_macOS: View {
if !trimmedGlobalQuery.isEmpty { if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
} else if isShowingHome { } else if isShowingHome {
HomeView(apiClient: apiClient, onOpenDocument: openDocument) HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
} else if let selectedCollection { } else if let selectedCollection {
CollectionOverviewView( CollectionOverviewView(
apiClient: apiClient, apiClient: apiClient,
@@ -269,7 +275,8 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty { if !documentPath.isEmpty {
documentPath.removeLast() documentPath.removeLast()
} }
} },
onDocumentCreated: { documentsChangedToken += 1 }
) )
} }
} }
@@ -23,6 +23,11 @@ struct DocumentReaderView: View {
/// longer visible in the collection it was opened from, so the reader /// longer visible in the collection it was opened from, so the reader
/// pops itself off the navigation stack. /// pops itself off the navigation stack.
let onDeleted: () -> Void let onDeleted: () -> Void
/// The reader's own "New Document" toolbar button has no direct handle
/// on the sidebar row it belongs under this tells the sidebar a
/// document exists now so it can pick it up. See
/// `CollectionDocumentsOutline.externalRefreshToken`.
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false @State private var isShowingUnpublishConfirmation = false
@State private var isShowingArchiveConfirmation = false @State private var isShowingArchiveConfirmation = false
@@ -40,13 +45,15 @@ struct DocumentReaderView: View {
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
document: OutlineDocument, document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void, onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void onDeleted: @escaping () -> Void,
onDocumentCreated: @escaping () -> Void
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild self.onOpenChild = onOpenChild
self.onDeleted = onDeleted self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
} }
var body: some View { var body: some View {
@@ -213,6 +220,7 @@ struct DocumentReaderView: View {
} }
.sheet(isPresented: $isShowingNewDocumentSheet) { .sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
onDocumentCreated()
onOpenChild(child) onOpenChild(child)
} }
} }
+7 -1
View File
@@ -5,6 +5,10 @@ import OutlineKit
struct HomeView: View { struct HomeView: View {
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let onOpenDocument: (OutlineDocument) -> Void 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 viewModel: HomeViewModel
@State private var selectedTab: HomeTab = .recentlyViewed @State private var selectedTab: HomeTab = .recentlyViewed
@@ -13,9 +17,10 @@ struct HomeView: View {
private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)] private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)]
private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)]
init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) { init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void, onDocumentCreated: @escaping () -> Void) {
self.apiClient = apiClient self.apiClient = apiClient
self.onOpenDocument = onOpenDocument self.onOpenDocument = onOpenDocument
self.onDocumentCreated = onDocumentCreated
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
} }
@@ -51,6 +56,7 @@ struct HomeView: View {
} }
.sheet(isPresented: $isShowingNewDocumentSheet) { .sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient) { document in NewDocumentSheet(apiClient: apiClient) { document in
onDocumentCreated()
onOpenDocument(document) onOpenDocument(document)
} }
} }