From 4bec511b62ae7d3c4926231836b0d9120a08b372 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:56:33 +0100 Subject: [PATCH] feat(collections): add sidebar collection context menu (macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../CollectionDocumentsOutline.swift | 20 ++- .../Collections/CollectionTreeRow.swift | 129 ++++++++++++++++++ .../Collections/CollectionsTreeView.swift | 9 +- .../Collections/ContentView_macOS.swift | 13 +- .../Collections/SidebarSortOption.swift | 39 ++++++ 5 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 Outpost/Features/Collections/SidebarSortOption.swift diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 7524a92..098a150 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index fc3ebcf..bcfe9b8 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index e8121a7..84403cb 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -7,15 +7,18 @@ struct CollectionsTreeView: View { @Binding private var selectedCollection: OutlineCollection? @State private var expandedCollectionIDs: Set = [] let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void + let onSearchInCollection: (OutlineCollection) -> Void init( apiClient: OutlineAPIClient, selectedCollection: Binding, - 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() } ) } } diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 8d7b988..7fb7ab3 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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 diff --git a/Outpost/Features/Collections/SidebarSortOption.swift b/Outpost/Features/Collections/SidebarSortOption.swift new file mode 100644 index 0000000..bd043ed --- /dev/null +++ b/Outpost/Features/Collections/SidebarSortOption.swift @@ -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 } + } + } +}