From 099126d3a2f146b34da74f526ef2271a68fc9449 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 01:48:35 -0700 Subject: [PATCH] WIP: wire Quick Look preview actions --- Package.swift | 2 +- .../views/ClipboardPanelController.swift | 2 +- .../clipbored/views/ClipboardPanelView.swift | 33 ++++++++- .../ClipboardCacheServiceTests.swift | 72 +++++++++++++++++++ .../ClipboardPanelControllerTests.swift | 1 + .../ClipboardPanelViewTests.swift | 16 ++++- 6 files changed, 121 insertions(+), 5 deletions(-) diff --git a/Package.swift b/Package.swift index 6de5404..8de9fdc 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( .linkedFramework("AppKit"), .linkedFramework("Carbon"), .linkedFramework("LocalAuthentication"), - .linkedFramework("QuickLook"), + .linkedFramework("QuickLookUI"), .linkedFramework("Security"), .linkedFramework("Vision"), .linkedLibrary("sqlite3") diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 3f52411..d49fb5a 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -1,5 +1,5 @@ import AppKit -import QuickLook +import QuickLookUI struct ClipboardPanelAnimationProfile { let showDuration: TimeInterval diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 079b091..8b5ddd5 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -50,6 +50,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let viewModel: ClipboardPanelViewModel private let onClose: () -> Void private let onSettings: () -> Void + private let onPreview: () -> Void private let searchField = NSSearchField() private let collectionScrollView = NSScrollView() @@ -76,10 +77,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private var collectionNameProviderForTesting: (() -> String?)? #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.onClose = onClose self.onSettings = onSettings + self.onPreview = onPreview super.init(frame: .zero) configureView() bindViewModel() @@ -521,6 +528,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.copySelected() } + card.onPreview = { [weak self] selected in + self?.viewModel.selectItem(at: selected) + self?.onPreview() + } card.onPasteboardWriters = { [weak self] selected in self?.viewModel.pasteboardWriters(forItemAt: selected) ?? [] } @@ -1264,6 +1275,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { var onSelect: (Int) -> Void = { _ in } var onPaste: (Int) -> Void = { _ in } var onCopy: (Int) -> Void = { _ in } + var onPreview: (Int) -> Void = { _ in } var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } var onOpen: (Int) -> Void = { _ in } var onReveal: (Int) -> Void = { _ in } @@ -1445,6 +1457,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { menu.autoenablesItems = false addMenuItem("Paste", action: #selector(pasteFromMenu), 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) addCollectionMenu(to: menu) 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 { switch itemKind { 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(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu)) ] + if canPreview { + actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu))) + } if canOpen { actionRailButtons.append(cardActionButton("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu))) } @@ -1623,6 +1650,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onCopy(index) } + @objc private func previewFromMenu() { + onPreview(index) + } + @objc private func openFromMenu() { onOpen(index) } diff --git a/tests/clipboredtests/ClipboardCacheServiceTests.swift b/tests/clipboredtests/ClipboardCacheServiceTests.swift index 1e7b391..83a9afc 100644 --- a/tests/clipboredtests/ClipboardCacheServiceTests.swift +++ b/tests/clipboredtests/ClipboardCacheServiceTests.swift @@ -191,6 +191,46 @@ final class ClipboardCacheServiceTests: XCTestCase { 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 { let url = FileManager.default.temporaryDirectory .appendingPathComponent("clipboredtests", isDirectory: true) @@ -205,6 +245,38 @@ final class ClipboardCacheServiceTests: XCTestCase { 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 { ClipboardItem( id: UUID(), diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index 635c0c5..21e865e 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -147,6 +147,7 @@ final class ClipboardPanelControllerTests: XCTestCase { func testCommandActionShortcutsMapToSelectedClipActions() { 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: 15, modifiers: .command), .reveal) } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 575b8f8..2eaf1a8 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -182,8 +182,8 @@ final class ClipboardPanelViewTests: XCTestCase { fixture.viewModel.selectFirstItem() XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Open", "Reveal", "Delete"]) - XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Preview", "Open", "Reveal", "Delete"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 210) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) 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() { let fixture = makePanelFixture() var existing = makeTextItem("Existing client note", store: fixture.store)