WIP: wire Quick Look preview actions
This commit is contained in:
@@ -18,7 +18,7 @@ let package = Package(
|
|||||||
.linkedFramework("AppKit"),
|
.linkedFramework("AppKit"),
|
||||||
.linkedFramework("Carbon"),
|
.linkedFramework("Carbon"),
|
||||||
.linkedFramework("LocalAuthentication"),
|
.linkedFramework("LocalAuthentication"),
|
||||||
.linkedFramework("QuickLook"),
|
.linkedFramework("QuickLookUI"),
|
||||||
.linkedFramework("Security"),
|
.linkedFramework("Security"),
|
||||||
.linkedFramework("Vision"),
|
.linkedFramework("Vision"),
|
||||||
.linkedLibrary("sqlite3")
|
.linkedLibrary("sqlite3")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import QuickLook
|
import QuickLookUI
|
||||||
|
|
||||||
struct ClipboardPanelAnimationProfile {
|
struct ClipboardPanelAnimationProfile {
|
||||||
let showDuration: TimeInterval
|
let showDuration: TimeInterval
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
private let viewModel: ClipboardPanelViewModel
|
private let viewModel: ClipboardPanelViewModel
|
||||||
private let onClose: () -> Void
|
private let onClose: () -> Void
|
||||||
private let onSettings: () -> Void
|
private let onSettings: () -> Void
|
||||||
|
private let onPreview: () -> Void
|
||||||
|
|
||||||
private let searchField = NSSearchField()
|
private let searchField = NSSearchField()
|
||||||
private let collectionScrollView = NSScrollView()
|
private let collectionScrollView = NSScrollView()
|
||||||
@@ -76,10 +77,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
private var collectionNameProviderForTesting: (() -> String?)?
|
private var collectionNameProviderForTesting: (() -> String?)?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(viewModel: ClipboardPanelViewModel, onClose: @escaping () -> Void, onSettings: @escaping () -> Void = {}) {
|
init(
|
||||||
|
viewModel: ClipboardPanelViewModel,
|
||||||
|
onClose: @escaping () -> Void,
|
||||||
|
onSettings: @escaping () -> Void = {},
|
||||||
|
onPreview: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.onClose = onClose
|
self.onClose = onClose
|
||||||
self.onSettings = onSettings
|
self.onSettings = onSettings
|
||||||
|
self.onPreview = onPreview
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
configureView()
|
configureView()
|
||||||
bindViewModel()
|
bindViewModel()
|
||||||
@@ -521,6 +528,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
self?.viewModel.selectItem(at: selected)
|
self?.viewModel.selectItem(at: selected)
|
||||||
self?.viewModel.copySelected()
|
self?.viewModel.copySelected()
|
||||||
}
|
}
|
||||||
|
card.onPreview = { [weak self] selected in
|
||||||
|
self?.viewModel.selectItem(at: selected)
|
||||||
|
self?.onPreview()
|
||||||
|
}
|
||||||
card.onPasteboardWriters = { [weak self] selected in
|
card.onPasteboardWriters = { [weak self] selected in
|
||||||
self?.viewModel.pasteboardWriters(forItemAt: selected) ?? []
|
self?.viewModel.pasteboardWriters(forItemAt: selected) ?? []
|
||||||
}
|
}
|
||||||
@@ -1264,6 +1275,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
var onSelect: (Int) -> Void = { _ in }
|
var onSelect: (Int) -> Void = { _ in }
|
||||||
var onPaste: (Int) -> Void = { _ in }
|
var onPaste: (Int) -> Void = { _ in }
|
||||||
var onCopy: (Int) -> Void = { _ in }
|
var onCopy: (Int) -> Void = { _ in }
|
||||||
|
var onPreview: (Int) -> Void = { _ in }
|
||||||
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
|
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
|
||||||
var onOpen: (Int) -> Void = { _ in }
|
var onOpen: (Int) -> Void = { _ in }
|
||||||
var onReveal: (Int) -> Void = { _ in }
|
var onReveal: (Int) -> Void = { _ in }
|
||||||
@@ -1445,6 +1457,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
menu.autoenablesItems = false
|
menu.autoenablesItems = false
|
||||||
addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu)
|
addMenuItem("Paste", action: #selector(pasteFromMenu), to: menu)
|
||||||
addMenuItem("Copy", action: #selector(copyFromMenu), to: menu)
|
addMenuItem("Copy", action: #selector(copyFromMenu), to: menu)
|
||||||
|
if canPreview {
|
||||||
|
addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu)
|
||||||
|
}
|
||||||
addMenuItem(itemIsPinned ? "Unpin" : "Pin", action: #selector(togglePinFromMenu), to: menu)
|
addMenuItem(itemIsPinned ? "Unpin" : "Pin", action: #selector(togglePinFromMenu), to: menu)
|
||||||
addCollectionMenu(to: menu)
|
addCollectionMenu(to: menu)
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
@@ -1519,6 +1534,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canPreview: Bool {
|
||||||
|
switch itemKind {
|
||||||
|
case .url, .image, .richText, .file, .pdf, .audio:
|
||||||
|
return true
|
||||||
|
case .text, .unknown:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var canReveal: Bool {
|
private var canReveal: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio:
|
||||||
@@ -1553,6 +1577,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)),
|
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)),
|
||||||
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
|
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
|
||||||
]
|
]
|
||||||
|
if canPreview {
|
||||||
|
actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu)))
|
||||||
|
}
|
||||||
if canOpen {
|
if canOpen {
|
||||||
actionRailButtons.append(cardActionButton("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu)))
|
actionRailButtons.append(cardActionButton("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu)))
|
||||||
}
|
}
|
||||||
@@ -1623,6 +1650,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
onCopy(index)
|
onCopy(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func previewFromMenu() {
|
||||||
|
onPreview(index)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func openFromMenu() {
|
@objc private func openFromMenu() {
|
||||||
onOpen(index)
|
onOpen(index)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,46 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTemporaryPreviewURLWritesTextFile() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||||
|
let item = textItem("Quick Look text")
|
||||||
|
|
||||||
|
let previewURL = try XCTUnwrap(cacheService.temporaryPreviewURL(for: item))
|
||||||
|
|
||||||
|
XCTAssertEqual(previewURL.pathExtension, "txt")
|
||||||
|
XCTAssertEqual(try String(contentsOf: previewURL), "Quick Look text")
|
||||||
|
XCTAssertEqual(try posixPermissions(previewURL.deletingLastPathComponent()), 0o700)
|
||||||
|
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTemporaryPreviewURLWritesWebLocationFile() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||||
|
let item = urlItem("https://example.com/releases")
|
||||||
|
|
||||||
|
let previewURL = try XCTUnwrap(cacheService.temporaryPreviewURL(for: item))
|
||||||
|
let data = try Data(contentsOf: previewURL)
|
||||||
|
let plist = try XCTUnwrap(
|
||||||
|
PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: String]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(previewURL.pathExtension, "webloc")
|
||||||
|
XCTAssertEqual(plist["URL"], "https://example.com/releases")
|
||||||
|
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTemporaryPreviewURLReturnsExistingFileURL() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
|
||||||
|
let fileURL = baseURL.appendingPathComponent("report.txt")
|
||||||
|
try Data("Report".utf8).write(to: fileURL)
|
||||||
|
|
||||||
|
let previewURL = try XCTUnwrap(cacheService.temporaryPreviewURL(for: fileItem(path: fileURL.path)))
|
||||||
|
|
||||||
|
XCTAssertEqual(previewURL.standardizedFileURL, fileURL.standardizedFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeTempDirectory() throws -> URL {
|
private func makeTempDirectory() throws -> URL {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||||
@@ -205,6 +245,38 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
|
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func textItem(_ text: String) -> ClipboardItem {
|
||||||
|
ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .text,
|
||||||
|
displayText: text,
|
||||||
|
payload: text,
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: nil,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urlItem(_ url: String) -> ClipboardItem {
|
||||||
|
ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .url,
|
||||||
|
displayText: url,
|
||||||
|
payload: url,
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: nil,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func pdfItem(path: String) -> ClipboardItem {
|
private func pdfItem(path: String) -> ClipboardItem {
|
||||||
ClipboardItem(
|
ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
|
|
||||||
func testCommandActionShortcutsMapToSelectedClipActions() {
|
func testCommandActionShortcutsMapToSelectedClipActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
|
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
|
||||||
|
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
|
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal)
|
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
|
|
||||||
fixture.viewModel.selectFirstItem()
|
fixture.viewModel.selectFirstItem()
|
||||||
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
|
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Open", "Reveal", "Delete"])
|
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Preview", "Open", "Reveal", "Delete"])
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182)
|
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210)
|
||||||
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
|
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
|
||||||
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
|
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
|
||||||
}
|
}
|
||||||
@@ -329,6 +329,18 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewableCardsExposeQuickLookContextMenuAction() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))
|
||||||
|
drainMainQueue()
|
||||||
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
fixture.view.debugFirstCardMenuTitles,
|
||||||
|
["Paste", "Copy", "Quick Look", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testCollectionMenuOffersExistingCustomCollections() {
|
func testCollectionMenuOffersExistingCustomCollections() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
var existing = makeTextItem("Existing client note", store: fixture.store)
|
var existing = makeTextItem("Existing client note", store: fixture.store)
|
||||||
|
|||||||
Reference in New Issue
Block a user