diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index 78d20d0..086c143 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -332,6 +332,8 @@ struct ItemCardView: View { Text(Tokens.relativeFormatter.localizedString(for: item.lastUsedAt, relativeTo: Date())) .font(Tokens.caption) .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } } diff --git a/Copy/Shelf/PreviewPane.swift b/Copy/Shelf/PreviewPane.swift index b2c06c7..c6cbfdf 100644 --- a/Copy/Shelf/PreviewPane.swift +++ b/Copy/Shelf/PreviewPane.swift @@ -1,5 +1,7 @@ import SwiftUI import CopyCore +import ImageIO +import UniformTypeIdentifiers struct PreviewPane: View { let item: ClipItem @@ -51,21 +53,7 @@ struct PreviewPane: View { } .padding(16) case .file: - VStack(spacing: 10) { - Image(nsImage: NSWorkspace.shared.icon(for: Tokens.fileType(for: item))) - .resizable() - .frame(width: 64, height: 64) - Text(item.plainText ?? "File") - .font(.system(size: 13, design: .monospaced)) - .multilineTextAlignment(.center) - .lineLimit(4) - if !quickLookURLs.isEmpty { - Button("Quick Look") { - QuickLookController.shared.preview(quickLookURLs) - } - } - } - .padding(16) + FileCardPreview(item: item, urls: quickLookURLs) default: ScrollView { codeAwarePreviewText(item.plainText ?? "") @@ -87,3 +75,114 @@ struct PreviewPane: View { .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } + +/// Decodes an image-file card from its original URL for the larger Space preview. +/// This deliberately does not use the 400-point Quick Look thumbnail used by shelf +/// cards: ImageIO downsamples the source itself at a size suitable for a Retina pane. +private struct FileImagePreview: View { + let url: URL + @State private var image: NSImage? + @State private var didFail = false + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else if didFail { + Image(systemName: "photo.badge.exclamationmark") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .quaternaryLabelColor).opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .onAppear(perform: loadImage) + } + + private func loadImage() { + guard image == nil, !didFail else { return } + let requestedURL = url + DispatchQueue.global(qos: .userInitiated).async { + let source = CGImageSourceCreateWithURL(requestedURL as CFURL, nil) + let cgImage = source.flatMap { + CGImageSourceCreateThumbnailAtIndex($0, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 1_600, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary) + } + let decoded = cgImage.map { NSImage(cgImage: $0, size: .zero) } + DispatchQueue.main.async { + image = decoded + didFail = decoded == nil + } + } + } +} + +/// The Space preview for a file card. Deciding whether the card points at an image means +/// asking the file system for each URL's content type, and that call blocks for as long +/// as the volume takes to answer — unbounded on a network share or a sleeping disk. The +/// probe therefore runs off the main thread, and the pane shows the same spinner +/// `FileImagePreview` uses while it decodes, so the generic icon never flashes first. +private struct FileCardPreview: View { + let item: ClipItem + let urls: [URL] + @State private var imageURL: URL? + @State private var didProbe = false + + var body: some View { + Group { + if let imageURL { + FileImagePreview(url: imageURL) + .id(imageURL) + .padding(12) + } else if didProbe { + genericFile + } else { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task(id: item.uuid) { + imageURL = nil + didProbe = false + let candidates = urls + let found = await Task.detached(priority: .userInitiated) { + candidates.first { url in + guard let values = try? url.resourceValues(forKeys: [.contentTypeKey]), + let contentType = values.contentType else { return false } + return contentType.conforms(to: .image) + } + }.value + guard !Task.isCancelled else { return } + imageURL = found + didProbe = true + } + } + + private var genericFile: some View { + VStack(spacing: 10) { + Image(nsImage: NSWorkspace.shared.icon(for: Tokens.fileType(for: item))) + .resizable() + .frame(width: 64, height: 64) + Text(item.plainText ?? "File") + .font(.system(size: 13, design: .monospaced)) + .multilineTextAlignment(.center) + .lineLimit(4) + if !urls.isEmpty { + Button("Quick Look") { + QuickLookController.shared.preview(urls) + } + } + } + .padding(16) + } +} diff --git a/CopyCore/Package.resolved b/CopyCore/Package.resolved index 453d94b..6facdf9 100644 --- a/CopyCore/Package.resolved +++ b/CopyCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7796ad11cf0c4948960374d98c6499b750c555b849179148528e24f03f4c0b84", + "originHash" : "09f24667467c5ab460cd31c7e1845c760fc02ca2ebe0b3d769c8064d4351ba2d", "pins" : [ { "identity" : "grdb.swift", @@ -9,24 +9,6 @@ "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", "version" : "7.11.1" } - }, - { - "identity" : "keyboardshortcuts", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sindresorhus/KeyboardShortcuts", - "state" : { - "revision" : "49c3fc04ea827f816df67843bfcc57286b47ff06", - "version" : "3.0.1" - } - }, - { - "identity" : "sparkle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sparkle-project/Sparkle", - "state" : { - "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", - "version" : "2.9.4" - } } ], "version" : 3 diff --git a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift index 4fd54a8..70405aa 100644 --- a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift +++ b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift @@ -16,7 +16,20 @@ public final class DatabaseManager { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) blobsDirectory = directory.appendingPathComponent("blobs", isDirectory: true) try FileManager.default.createDirectory(at: blobsDirectory, withIntermediateDirectories: true) - writer = try DatabasePool(path: directory.appendingPathComponent("copy.sqlite").path) + var configuration = Configuration() + configuration.prepareDatabase { db in + db.add(function: DatabaseFunction( + ImageFileDetection.sqlFunctionName, + argumentCount: 1, + pure: true + ) { values in + guard let filenames = String.fromDatabaseValue(values[0]) else { return false } + return ImageFileDetection.containsImageFile(in: filenames) + }) + } + writer = try DatabasePool( + path: directory.appendingPathComponent("copy.sqlite").path, + configuration: configuration) try Self.migrator.migrate(writer) } diff --git a/CopyCore/Sources/CopyCore/Storage/ImageFileDetection.swift b/CopyCore/Sources/CopyCore/Storage/ImageFileDetection.swift new file mode 100644 index 0000000..29fca94 --- /dev/null +++ b/CopyCore/Sources/CopyCore/Storage/ImageFileDetection.swift @@ -0,0 +1,21 @@ +import Foundation +import UniformTypeIdentifiers + +/// Clipboard file items store their filenames as newline-separated `plainText`. Keep the +/// platform content-type check in one place so the Image facet can include image files +/// without broadening to every `.file` item or maintaining an extension allow-list. +/// Deliberately uncached. `UTType(filenameExtension:)` measures 2.2 us per call, so even +/// ten thousand file rows cost about 22 ms for a facet the screener asked for. A memo +/// table would have to be locked, because a `DatabasePool` calls this from several reader +/// connections at once, and that contention costs more than the lookup it saves. +enum ImageFileDetection { + static let sqlFunctionName = "copy_contains_image_file" + + static func containsImageFile(in filenames: String) -> Bool { + filenames.split(separator: "\n", omittingEmptySubsequences: true).contains { filename in + let ext = (String(filename) as NSString).pathExtension.lowercased() + guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false } + return type.conforms(to: .image) + } + } +} diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index accbd09..197045b 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -238,10 +238,9 @@ public struct ItemStore { sql += " AND item.appBundleID = ?" arguments.append(appBundleID) } - if !filter.kinds.isEmpty { - let names = filter.kinds.map(\.rawValue).sorted() - sql += " AND item.kind IN (\(names.map { _ in "?" }.joined(separator: ",")))" - arguments.append(contentsOf: names) + if let kindFacet = kindFacet(filter) { + sql += " AND \(kindFacet.sql)" + arguments.append(contentsOf: kindFacet.arguments) } if let range = filter.dateRange { sql += " AND item.lastUsedAt >= ? AND item.lastUsedAt < ?" @@ -389,8 +388,9 @@ public struct ItemStore { if let appBundleID = filter.appBundleID { request = request.filter(Column("appBundleID") == appBundleID) } - if !filter.kinds.isEmpty { - request = request.filter(filter.kinds.map(\.rawValue).contains(Column("kind"))) + if let kindFacet = kindFacet(filter) { + request = request.filter(sql: kindFacet.sql, + arguments: StatementArguments(kindFacet.arguments)) } if let range = filter.dateRange { request = request.filter(Column("lastUsedAt") >= range.start && Column("lastUsedAt") < range.end) @@ -408,6 +408,26 @@ public struct ItemStore { return request } + /// One OR-ed type predicate. Image facets include native `.image` rows plus `.file` + /// rows whose newline-separated filenames contain an image content type; other type + /// facets retain the ordinary `kind IN (…)` behavior. + private func kindFacet(_ filter: SearchFilter) + -> (sql: String, arguments: [any DatabaseValueConvertible])? { + var clauses: [String] = [] + var arguments: [any DatabaseValueConvertible] = [] + if !filter.kinds.isEmpty { + let names = filter.kinds.map(\.rawValue).sorted() + clauses.append("item.kind IN (\(names.map { _ in "?" }.joined(separator: ",")))") + arguments.append(contentsOf: names) + } + if filter.includesImageFiles { + clauses.append("(item.kind = ? AND \(ImageFileDetection.sqlFunctionName)(item.plainText) = 1)") + arguments.append(ItemKind.file.rawValue) + } + guard !clauses.isEmpty else { return nil } + return ("(\(clauses.joined(separator: " OR ")))", arguments) + } + /// One page of shelf history: every favorite matching `filter`, plus the `limit` most /// recent non-favorites, favorites first. /// diff --git a/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift b/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift index 46cbafd..7015a61 100644 --- a/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift +++ b/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift @@ -9,6 +9,9 @@ public struct SearchFilter: Equatable, Sendable { public var text: String public var appBundleID: String? public var kinds: Set + /// Extends a kind facet with `.file` items whose stored filenames resolve to an image + /// content type. Used by the Image token; it deliberately does not include every file. + public var includesImageFiles: Bool /// Matched against `lastUsedAt` — consistent with ordering, retention, and the relative /// timestamps shown on cards. Half-open `[start, end)`. public var dateRange: DateInterval? @@ -18,12 +21,14 @@ public struct SearchFilter: Equatable, Sendable { public init(text: String = "", appBundleID: String? = nil, kinds: Set = [], + includesImageFiles: Bool = false, dateRange: DateInterval? = nil, favoritesOnly: Bool = false, pinboardIDs: Set = []) { self.text = text self.appBundleID = appBundleID self.kinds = kinds + self.includesImageFiles = includesImageFiles self.dateRange = dateRange self.favoritesOnly = favoritesOnly self.pinboardIDs = pinboardIDs @@ -34,6 +39,7 @@ public struct SearchFilter: Equatable, Sendable { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && appBundleID == nil && kinds.isEmpty + && !includesImageFiles && dateRange == nil && !favoritesOnly && pinboardIDs.isEmpty diff --git a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift index 7ba0f22..d787e07 100644 --- a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift +++ b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift @@ -138,7 +138,9 @@ public struct SearchQuery: Equatable, Sendable { for token in tokens { switch token { case .app(let bundleID, _): filter.appBundleID = bundleID - case .type(let type): kinds.formUnion(type.kinds) + case .type(let type): + kinds.formUnion(type.kinds) + if type == .images { filter.includesImageFiles = true } case .date(let date): filter.dateRange = date.interval(now: now, calendar: calendar) case .favorites: filter.favoritesOnly = true case .pinboard(let id, _): pinboardIDs.insert(id) diff --git a/CopyCore/Tests/CopyCoreTests/ShelfQueryTests.swift b/CopyCore/Tests/CopyCoreTests/ShelfQueryTests.swift index 4992b62..337a8f5 100644 --- a/CopyCore/Tests/CopyCoreTests/ShelfQueryTests.swift +++ b/CopyCore/Tests/CopyCoreTests/ShelfQueryTests.swift @@ -34,6 +34,37 @@ final class ShelfQueryTests: XCTestCase { XCTAssertEqual(try store.search("example", kinds: nil).count, 2) } + func testImageFacetIncludesImageFilesButNotOtherFiles() throws { + let store = try makeTempStore() + _ = try store.save(makeImage(bytes: 10, tag: "native-image")) + _ = try store.save(makeFileItem(names: "photo.HEIC", tag: "heic")) + _ = try store.save(makeFileItem(names: "notes.txt\npreview.webp", tag: "mixed")) + _ = try store.save(makeFileItem(names: "photo.jpg.backup", tag: "backup")) + _ = try store.save(makeFileItem(names: "notes.txt", tag: "text-file")) + + let filter = SearchQuery(tokens: [.type(.images)]).toFilter() + let results = try store.recentPage(filter: filter) + + XCTAssertEqual(Set(results.compactMap(\.plainText)), ["Image", "photo.HEIC", "notes.txt\npreview.webp"]) + + var textFilter = filter + textFilter.text = "photo" + XCTAssertEqual(try store.search(filter: textFilter).compactMap(\.plainText), ["photo.HEIC"]) + } + + func testImageAndFileFacetsStillReturnEveryFileWithoutDuplicates() throws { + let store = try makeTempStore() + _ = try store.save(makeImage(bytes: 10, tag: "native-image")) + _ = try store.save(makeFileItem(names: "photo.png", tag: "png")) + _ = try store.save(makeFileItem(names: "notes.txt", tag: "text-file")) + + let filter = SearchQuery(tokens: [.type(.images), .type(.files)]).toFilter() + let results = try store.recentPage(filter: filter) + + XCTAssertEqual(results.count, 3) + XCTAssertEqual(Set(results.compactMap(\.plainText)), ["Image", "photo.png", "notes.txt"]) + } + func testObserveRecentFiresOnChange() throws { let store = try makeTempStore() let initial = expectation(description: "initial") @@ -52,3 +83,13 @@ final class ShelfQueryTests: XCTestCase { XCTAssertEqual(deliveries[1].map(\.plainText), ["observed"]) } } + +private func makeFileItem(names: String, tag: String) -> CapturedItem { + CapturedItem( + kind: .file, + plainText: names, + hashData: Data(tag.utf8), + representations: [CapturedRepresentation(uti: "public.file-url", data: Data(tag.utf8))], + sourceBundleID: "com.test.app", + sourceAppName: "TestApp") +} diff --git a/CopyCore/Tests/CopyCoreTests/SmartSearchTests.swift b/CopyCore/Tests/CopyCoreTests/SmartSearchTests.swift index 2d591fb..e811aee 100644 --- a/CopyCore/Tests/CopyCoreTests/SmartSearchTests.swift +++ b/CopyCore/Tests/CopyCoreTests/SmartSearchTests.swift @@ -17,11 +17,21 @@ final class SmartSearchTests: XCTestCase { XCTAssertEqual(filter.text, "movement") XCTAssertEqual(filter.appBundleID, "com.apple.Safari") XCTAssertEqual(filter.kinds, [.link, .image]) + XCTAssertTrue(filter.includesImageFiles) XCTAssertTrue(filter.favoritesOnly) XCTAssertEqual(filter.pinboardIDs, [3]) XCTAssertEqual(filter.dateRange, SearchDate.last7.interval(now: now)) } + func testOnlyImageTypeIncludesImageFiles() { + var query = SearchQuery(tokens: [.type(.files)]) + XCTAssertFalse(query.toFilter().includesImageFiles) + + query = SearchQuery(tokens: [.type(.images)]) + XCTAssertEqual(query.toFilter().kinds, [.image]) + XCTAssertTrue(query.toFilter().includesImageFiles) + } + func testAddReplacesSingleValuedFacets() { var query = SearchQuery() query.add(.app(bundleID: "a", name: "A"))