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:
@@ -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 "A–Z"
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user