feat(collections): add sidebar collection context menu (macOS)

Star, New Document, Rename, Sort in Sidebar (client-side only —
Outline's own spec leaves persisted sort order as a frontend concern),
Export, Search in Collection, and Delete. Omits Subscribe and Archive
(no such endpoints exist for collections in Outline's public API),
and Import/Edit/Permissions/New Template (need infra not built yet:
multipart upload, membership UI, template flow).
This commit is contained in:
2026-08-14 00:56:33 +01:00
parent 7f921ba006
commit 4bec511b62
5 changed files with 204 additions and 6 deletions
@@ -7,10 +7,24 @@ import OutlineKit
/// represented here yet, this is a first pass at the collection-level expansion.
struct CollectionDocumentsOutline: View {
@State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption
let refreshToken: Int
let onSelectDocument: (OutlineDocument) -> Void
init(apiClient: OutlineAPIClient, collection: OutlineCollection, onSelectDocument: @escaping (OutlineDocument) -> Void) {
private var sortedDocuments: [OutlineDocument] {
sortOption.sorted(viewModel.documents)
}
init(
apiClient: OutlineAPIClient,
collection: OutlineCollection,
sortOption: SidebarSortOption,
refreshToken: Int,
onSelectDocument: @escaping (OutlineDocument) -> Void
) {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
self.refreshToken = refreshToken
self.onSelectDocument = onSelectDocument
}
@@ -26,7 +40,7 @@ struct CollectionDocumentsOutline: View {
.foregroundStyle(.secondary)
.padding(.vertical, 6)
} else {
ForEach(viewModel.documents) { document in
ForEach(sortedDocuments) { document in
Button {
onSelectDocument(document)
} label: {
@@ -52,7 +66,7 @@ struct CollectionDocumentsOutline: View {
}
}
}
.task { await viewModel.load() }
.task(id: refreshToken) { await viewModel.load() }
}
}
#endif
@@ -9,6 +9,16 @@ struct CollectionTreeRow: View {
let isSelected: Bool
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 actionErrorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -34,16 +44,135 @@ struct CollectionTreeRow: View {
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
if isExpanded {
CollectionDocumentsOutline(
apiClient: apiClient,
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
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 ?? "")
}
}
@ViewBuilder
private var contextMenuContent: some View {
Button("Star") {
Task { await star() }
}
Divider()
Button("New Document") {
Task { await createDocument() }
}
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 apiClient.starCollection(StarCollectionRequest(collectionId: collection.id))
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this collection.")
}
}
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))
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
@@ -7,15 +7,18 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection?
@State private var expandedCollectionIDs: Set<String> = []
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
init(
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection
self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection
}
var body: some View {
@@ -48,7 +51,9 @@ struct CollectionsTreeView: View {
isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id,
onToggle: { toggle(collection) },
onSelectDocument: { document in onSelectDocument(collection, document) }
onSelectDocument: { document in onSelectDocument(collection, document) },
onSearchInCollection: onSearchInCollection,
onCollectionsChanged: { await viewModel.load() }
)
}
}
@@ -8,6 +8,7 @@ struct ContentView_macOS: View {
@State private var documentPath: [OutlineDocument] = []
@State private var globalSearchQuery = ""
@State private var contextualSearchQuery = ""
@FocusState private var isContextualSearchFocused: Bool
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -45,6 +46,7 @@ struct ContentView_macOS: View {
placement: .toolbar,
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
)
.searchFocused($isContextualSearchFocused)
}
@ViewBuilder
@@ -93,13 +95,22 @@ struct ContentView_macOS: View {
CollectionsTreeView(
apiClient: apiClient,
selectedCollection: $selectedCollection,
onSelectDocument: selectDocument
onSelectDocument: selectDocument,
onSearchInCollection: searchInCollection
)
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
private func searchInCollection(_ collection: OutlineCollection) {
globalSearchQuery = ""
selectedCollection = collection
Task { @MainActor in
isContextualSearchFocused = true
}
}
/// `documentPath = [document]` (replacing a same-size array in one step)
/// unreliably updates `NavigationStack` on macOS the stack can silently
/// keep showing the previous document. Popping to root and pushing again
@@ -0,0 +1,39 @@
import Foundation
import OutlineKit
/// Ordering for the nested document outline under a sidebar collection.
///
/// Outline's `Collection.sort` field exists for this, but its own OpenAPI spec
/// notes it's "left as a frontend concern to implement" and `collections.update`
/// doesn't document a `sort` param to persist it so this stays a local,
/// per-session preference rather than a synced setting.
enum SidebarSortOption: String, CaseIterable, Identifiable {
case manual
case alphabetical
case dateCreated
case dateUpdated
var id: String { rawValue }
var label: String {
switch self {
case .manual: return "Manual"
case .alphabetical: return "AZ"
case .dateCreated: return "Date Created"
case .dateUpdated: return "Date Updated"
}
}
func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] {
switch self {
case .manual:
return documents
case .alphabetical:
return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
case .dateCreated:
return documents.sorted { $0.createdAt > $1.createdAt }
case .dateUpdated:
return documents.sorted { $0.updatedAt > $1.updatedAt }
}
}
}