Skip to content

Commit

Permalink
feat: Preview bookmarks using Quick Look on macOS (#478)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbmorley authored May 27, 2023
1 parent 899af12 commit 45a5034
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 58 deletions.
2 changes: 1 addition & 1 deletion SelectableCollectionView
83 changes: 50 additions & 33 deletions core/Sources/BookmarksCore/Store/BookmarksView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,20 @@ public class BookmarksView: ObservableObject, Runnable {
@Published public var title: String
@Published public var subtitle: String = ""
@Published public var bookmarks: [Bookmark] = []
@Published public var urls: [URL] = []
@Published public var state: State = .loading
@Published public var filter: String = ""
@Published public var tokens: [String] = []
@Published public var suggestedTokens: [String] = []
@Published public var layoutMode: LayoutMode
@Published public var selection: Set<Bookmark.ID> = []
@Published public var previewURL: URL? = nil

@Published public var sheet: SheetType? = nil
@Published public var lastError: Error? = nil

@Published private var query: AnyQuery
@Published private var bookmarksLookup: [Bookmark.ID: Bookmark] = [:]

private let manager: BookmarksManager?
private let section: BookmarksSection
Expand Down Expand Up @@ -87,19 +90,24 @@ public class BookmarksView: ObservableObject, Runnable {
.receive(on: DispatchQueue.global())
.asyncMap { (_, query) in
do {
return (query, try await manager.database.bookmarks(query: query))
let bookmarks = try await manager.database.bookmarks(query: query)
let bookmarksLookup = bookmarks.reduce(into: [Bookmark.ID: Bookmark]()) { $0[$1.id] = $1 }
let urls = bookmarks.map { $0.url }
return (query, bookmarks, bookmarksLookup, urls)
} catch {
return (query, [])
return (query, [], [:], [])
}
}
.receive(on: DispatchQueue.main)
.sink { query, bookmarks in
.sink { query, bookmarks, bookmarksLookup, urls in
// Guard against updating the bookmarks with results from an old query.
guard self.query == query else {
print("Discarding query")
return
}
self.bookmarks = bookmarks
self.bookmarksLookup = bookmarksLookup
self.urls = urls
self.state = .ready
}
.store(in: &cancellables)
Expand Down Expand Up @@ -173,6 +181,19 @@ public class BookmarksView: ObservableObject, Runnable {
}
.store(in: &cancellables)

// Update the selection if the preview URL changes.
$previewURL
.compactMap { $0 }
.debounce(for: 0.2, scheduler: DispatchQueue.global(qos: .userInteractive))
.combineLatest($bookmarks)
.compactMap { url, bookmarks in
bookmarks.first { $0.url == url }?.id
}
.map { Set([$0]) }
.receive(on: DispatchQueue.main)
.assign(to: \.selection, on: self)
.store(in: &cancellables)

}

@MainActor public func stop() {
Expand All @@ -185,14 +206,9 @@ public class BookmarksView: ObservableObject, Runnable {
sheet = .addTags
}

@MainActor public func bookmarks(for ids: Set<Bookmark.ID>? = nil) async -> [Bookmark] {
@MainActor public func bookmarks(for ids: Set<Bookmark.ID>? = nil) -> [Bookmark] {
let ids = ids ?? self.selection
let bookmarks = bookmarks
return await withCheckedContinuation { continuation in
DispatchQueue.global().async {
continuation.resume(returning: bookmarks.filter { ids.contains($0.id) })
}
}
return ids.compactMap { bookmarksLookup[$0] }
}

@MainActor public func open(ids: Set<Bookmark.ID>? = nil, location: Bookmark.Location = .web) {
Expand All @@ -211,66 +227,62 @@ public class BookmarksView: ObservableObject, Runnable {
}
}

@MainActor public func update(ids: Set<Bookmark.ID>? = nil, toRead: Bool) async {
@MainActor public func update(ids: Set<Bookmark.ID>? = nil, toRead: Bool) {
guard let manager else {
return
}
let bookmarks = await bookmarks(for: ids)
let bookmarks = bookmarks(for: ids)
.map { $0.setting(toRead: toRead) }
manager.updateBookmarks(bookmarks, completion: errorHandler())
}

@MainActor public func update(ids: Set<Bookmark.ID>? = nil, shared: Bool) async {
@MainActor public func update(ids: Set<Bookmark.ID>? = nil, shared: Bool) {
guard let manager else {
return
}
let bookmarks = await bookmarks(for: ids)
let bookmarks = bookmarks(for: ids)
.map { $0.setting(shared: shared) }
manager.updateBookmarks(bookmarks, completion: errorHandler())
}

@MainActor public func delete(ids: Set<Bookmark.ID>? = nil) async {
@MainActor public func delete(ids: Set<Bookmark.ID>? = nil) {
guard let manager else {
return
}
let bookmarks = await bookmarks(for: ids)
let bookmarks = bookmarks(for: ids)
manager.deleteBookmarks(bookmarks, completion: errorHandler())
}

// TODO: Rethink the threading here.
@MainActor public func copy(ids: Set<Bookmark.ID>? = nil) async {
@MainActor public func copy(ids: Set<Bookmark.ID>? = nil) {
#if os(macOS)
let bookmarks = await bookmarks(for: ids)
DispatchQueue.main.async {
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects(bookmarks.map { $0.url.absoluteString as NSString })
NSPasteboard.general.writeObjects(bookmarks.map { $0.url as NSURL })
}
let bookmarks = bookmarks(for: ids)
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects(bookmarks.map { $0.url.absoluteString as NSString })
NSPasteboard.general.writeObjects(bookmarks.map { $0.url as NSURL })
#else
assertionFailure("Unsupported")
fatalError("Unsupported")
#endif
}

// TODO: Rethink the threading here.
@MainActor public func copyTags(ids: Set<Bookmark.ID>? = nil) async {
@MainActor public func copyTags(ids: Set<Bookmark.ID>? = nil) {
#if os(macOS)
let bookmarks = await bookmarks(for: ids)
let bookmarks = bookmarks(for: ids)
let tags = bookmarks.tags.sorted().joined(separator: " ")
DispatchQueue.main.async {
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([tags as NSString])
}
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([tags as NSString])
#else
assertionFailure("Unsupported")
fatalError("Unsupported")
#endif
}

// TODO: Consider whether we should pull this down into the manager.
@MainActor public func addTags(ids: Set<Bookmark.ID>? = nil, tags: Set<String>, markAsRead: Bool) async {
@MainActor public func addTags(ids: Set<Bookmark.ID>? = nil, tags: Set<String>, markAsRead: Bool) {
guard let manager else {
return
}
let bookmarks = await bookmarks(for: ids)
let bookmarks = bookmarks(for: ids)
.map { item in
item
.adding(tags: tags)
Expand All @@ -279,6 +291,11 @@ public class BookmarksView: ObservableObject, Runnable {
manager.updateBookmarks(bookmarks, completion: errorHandler({ _ in }))
}

@MainActor public func showPreview() {
let bookmarks = bookmarks()
previewURL = bookmarks.first?.url
}

// TODO: Set this asynchronously using combine.
@MainActor public var selectionContainsUnreadBookmarks: Bool {
let bookmarks = bookmarks.filter { selection.contains($0.id) }
Expand Down
2 changes: 1 addition & 1 deletion macos/Bookmarks/Commands/BookmarkDestructiveCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct BookmarkDesctructiveCommands: View {
var body: some View {
Button("Delete") {
Task {
await bookmarksView.delete()
bookmarksView.delete()
}
}
.keyboardShortcut(.delete, modifiers: [.command])
Expand Down
8 changes: 2 additions & 6 deletions macos/Bookmarks/Commands/BookmarkEditCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,13 @@ struct BookmarkEditCommands: View {
var body: some View {

Button(bookmarksView.selectionContainsUnreadBookmarks ? "Mark as Read" : "Mark as Unread") {
Task {
await bookmarksView.update(toRead: !bookmarksView.selectionContainsUnreadBookmarks)
}
bookmarksView.update(toRead: !bookmarksView.selectionContainsUnreadBookmarks)
}
.keyboardShortcut("U", modifiers: [.command, .shift])
.disabled(bookmarksView.selection.isEmpty)

Button(bookmarksView.selectionContainsPublicBookmark ? "Make Private" : "Make Public") {
Task {
await bookmarksView.update(shared: !bookmarksView.selectionContainsPublicBookmark)
}
bookmarksView.update(shared: !bookmarksView.selectionContainsPublicBookmark)
}
.disabled(bookmarksView.selection.isEmpty)

Expand Down
8 changes: 8 additions & 0 deletions macos/Bookmarks/Commands/BookmarkOpenCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ struct BookmarkOpenCommands: View {

var body: some View {

Button("Preview") {
bookmarksView.showPreview()
}
.keyboardShortcut(.space, modifiers: [])
.disabled(bookmarksView.selection.isEmpty)

Divider()

Button("Open") {
bookmarksView.open()
}
Expand Down
8 changes: 2 additions & 6 deletions macos/Bookmarks/Commands/BookmarkShareCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,13 @@ struct BookmarkShareCommands: View {

var body: some View {
Button("Copy") {
Task {
await bookmarksView.copy()
}
bookmarksView.copy()
}
.keyboardShortcut("c", modifiers: [.command])
.disabled(bookmarksView.selection.isEmpty)

Button("Copy Tags") {
Task {
await bookmarksView.copyTags()
}
bookmarksView.copyTags()
}
.keyboardShortcut("c", modifiers: [.command, .shift])
.disabled(bookmarksView.selection.isEmpty)
Expand Down
15 changes: 12 additions & 3 deletions macos/Bookmarks/Toolbars/SelectionToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ struct SelectionToolbar: CustomizableToolbarContent {

var body: some CustomizableToolbarContent {

ToolbarItem(id: "preview") {
Button {
bookmarksView.showPreview()
} label: {
Label("Preview", systemImage: "eye")
}
.help("Preview with Quick Look")
.keyboardShortcut(.space, modifiers: [])
.disabled(bookmarksView.selection.count != 1)
}

ToolbarItem(id: "open") {
Button {
bookmarksView.open(ids: bookmarksView.selection)
Expand All @@ -52,9 +63,7 @@ struct SelectionToolbar: CustomizableToolbarContent {

ToolbarItem(id: "delete") {
Button {
Task {
await bookmarksView.delete()
}
bookmarksView.delete()
} label: {
Label("Delete", systemImage: "trash")
}
Expand Down
4 changes: 1 addition & 3 deletions macos/Bookmarks/Views/AddTagsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,7 @@ struct AddTagsView: View {
.keyboardShortcut(.cancelAction)
Button {
let tags = tokens.compactMap { $0.associatedValue }
Task {
await bookmarksView.addTags(tags: Set(tags), markAsRead: markAsRead)
}
bookmarksView.addTags(tags: Set(tags), markAsRead: markAsRead)
dismiss()
} label: {
Text("OK")
Expand Down
26 changes: 21 additions & 5 deletions macos/Bookmarks/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Carbon
import Combine
import QuickLook
import SwiftUI

import BookmarksCore
Expand Down Expand Up @@ -52,25 +54,25 @@ struct ContentView: View {
}
Separator()
MenuItem("Delete") {
await bookmarksView.delete(ids: selection)
bookmarksView.delete(ids: selection)
}
Separator()
MenuItem(bookmarksView.selectionContainsUnreadBookmarks ? "Mark as Read" : "Mark as Unread") {
await bookmarksView.update(toRead: !bookmarksView.selectionContainsUnreadBookmarks)
bookmarksView.update(toRead: !bookmarksView.selectionContainsUnreadBookmarks)
}
MenuItem(bookmarksView.selectionContainsPublicBookmark ? "Make Private" : "Make Public") {
await bookmarksView.update(shared: !bookmarksView.selectionContainsPublicBookmark)
bookmarksView.update(shared: !bookmarksView.selectionContainsPublicBookmark)
}
Separator()
MenuItem("Edit on Pinboard") {
bookmarksView.open(ids: selection, location: .pinboard)
}
Separator()
MenuItem("Copy") {
await bookmarksView.copy(ids: selection)
bookmarksView.copy(ids: selection)
}
MenuItem("Copy Tags") {
await bookmarksView.copyTags(ids: selection)
bookmarksView.copyTags(ids: selection)
}
}

Expand All @@ -83,6 +85,7 @@ struct ContentView: View {

switch bookmarksView.layoutMode {
case .grid:

SelectableCollectionView(bookmarksView.bookmarks,
selection: $bookmarksView.selection,
layout: layout) { bookmark in
Expand All @@ -97,7 +100,19 @@ struct ContentView: View {
contextMenu(selection)
} primaryAction: { selection in
primaryAction(selection)
} keyDown: { event in
if event.keyCode == kVK_Space {
bookmarksView.showPreview()
return true
}
return false
} keyUp: { event in
if event.keyCode == kVK_Space {
return true
}
return false
}

case .table:

Table(bookmarksView.bookmarks, selection: $bookmarksView.selection) {
Expand All @@ -116,6 +131,7 @@ struct ContentView: View {

}
.overlay(bookmarksView.state == .loading ? LoadingView() : nil)
.quickLookPreview($bookmarksView.previewURL, in: bookmarksView.urls)
.runs(bookmarksView)
.searchable(text: $bookmarksView.filter,
tokens: $bookmarksView.tokens,
Expand Down

0 comments on commit 45a5034

Please sign in to comment.