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.
|
/// represented here yet, this is a first pass at the collection-level expansion.
|
||||||
struct CollectionDocumentsOutline: View {
|
struct CollectionDocumentsOutline: View {
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
|
let sortOption: SidebarSortOption
|
||||||
|
let refreshToken: Int
|
||||||
let onSelectDocument: (OutlineDocument) -> Void
|
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))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
|
self.sortOption = sortOption
|
||||||
|
self.refreshToken = refreshToken
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +40,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.documents) { document in
|
ForEach(sortedDocuments) { document in
|
||||||
Button {
|
Button {
|
||||||
onSelectDocument(document)
|
onSelectDocument(document)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -52,7 +66,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await viewModel.load() }
|
.task(id: refreshToken) { await viewModel.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ struct CollectionTreeRow: View {
|
|||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
let onSelectDocument: (OutlineDocument) -> 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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -34,16 +44,135 @@ struct CollectionTreeRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||||
|
.contextMenu { contextMenuContent }
|
||||||
|
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
CollectionDocumentsOutline(
|
CollectionDocumentsOutline(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
|
sortOption: sortOption,
|
||||||
|
refreshToken: documentsRefreshToken,
|
||||||
onSelectDocument: onSelectDocument
|
onSelectDocument: onSelectDocument
|
||||||
)
|
)
|
||||||
.padding(.leading, 18)
|
.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
|
#endif
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ struct CollectionsTreeView: View {
|
|||||||
@Binding private var selectedCollection: OutlineCollection?
|
@Binding private var selectedCollection: OutlineCollection?
|
||||||
@State private var expandedCollectionIDs: Set<String> = []
|
@State private var expandedCollectionIDs: Set<String> = []
|
||||||
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
|
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
|
||||||
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
selectedCollection: Binding<OutlineCollection?>,
|
selectedCollection: Binding<OutlineCollection?>,
|
||||||
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void
|
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void,
|
||||||
|
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
||||||
) {
|
) {
|
||||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||||
_selectedCollection = selectedCollection
|
_selectedCollection = selectedCollection
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
|
self.onSearchInCollection = onSearchInCollection
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -48,7 +51,9 @@ struct CollectionsTreeView: View {
|
|||||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||||
isSelected: selectedCollection?.id == collection.id,
|
isSelected: selectedCollection?.id == collection.id,
|
||||||
onToggle: { toggle(collection) },
|
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 documentPath: [OutlineDocument] = []
|
||||||
@State private var globalSearchQuery = ""
|
@State private var globalSearchQuery = ""
|
||||||
@State private var contextualSearchQuery = ""
|
@State private var contextualSearchQuery = ""
|
||||||
|
@FocusState private var isContextualSearchFocused: Bool
|
||||||
|
|
||||||
private var trimmedGlobalQuery: String {
|
private var trimmedGlobalQuery: String {
|
||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -45,6 +46,7 @@ struct ContentView_macOS: View {
|
|||||||
placement: .toolbar,
|
placement: .toolbar,
|
||||||
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
|
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
|
||||||
)
|
)
|
||||||
|
.searchFocused($isContextualSearchFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -93,13 +95,22 @@ struct ContentView_macOS: View {
|
|||||||
CollectionsTreeView(
|
CollectionsTreeView(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
selectedCollection: $selectedCollection,
|
selectedCollection: $selectedCollection,
|
||||||
onSelectDocument: selectDocument
|
onSelectDocument: selectDocument,
|
||||||
|
onSearchInCollection: searchInCollection
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
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)
|
/// `documentPath = [document]` (replacing a same-size array in one step)
|
||||||
/// unreliably updates `NavigationStack` on macOS — the stack can silently
|
/// unreliably updates `NavigationStack` on macOS — the stack can silently
|
||||||
/// keep showing the previous document. Popping to root and pushing again
|
/// 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