Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Copy/Shelf/ItemCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
129 changes: 114 additions & 15 deletions Copy/Shelf/PreviewPane.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import SwiftUI
import CopyCore
import ImageIO
import UniformTypeIdentifiers

struct PreviewPane: View {
let item: ClipItem
Expand Down Expand Up @@ -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 ?? "")
Expand All @@ -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)
}
}
20 changes: 1 addition & 19 deletions CopyCore/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
21 changes: 21 additions & 0 deletions CopyCore/Sources/CopyCore/Storage/ImageFileDetection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
32 changes: 26 additions & 6 deletions CopyCore/Sources/CopyCore/Storage/ItemStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 < ?"
Expand Down Expand Up @@ -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)
Expand All @@ -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.
///
Expand Down
6 changes: 6 additions & 0 deletions CopyCore/Sources/CopyCore/Storage/SearchFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public struct SearchFilter: Equatable, Sendable {
public var text: String
public var appBundleID: String?
public var kinds: Set<ItemKind>
/// 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?
Expand All @@ -18,12 +21,14 @@ public struct SearchFilter: Equatable, Sendable {
public init(text: String = "",
appBundleID: String? = nil,
kinds: Set<ItemKind> = [],
includesImageFiles: Bool = false,
dateRange: DateInterval? = nil,
favoritesOnly: Bool = false,
pinboardIDs: Set<Int64> = []) {
self.text = text
self.appBundleID = appBundleID
self.kinds = kinds
self.includesImageFiles = includesImageFiles
self.dateRange = dateRange
self.favoritesOnly = favoritesOnly
self.pinboardIDs = pinboardIDs
Expand All @@ -34,6 +39,7 @@ public struct SearchFilter: Equatable, Sendable {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& appBundleID == nil
&& kinds.isEmpty
&& !includesImageFiles
&& dateRange == nil
&& !favoritesOnly
&& pinboardIDs.isEmpty
Expand Down
4 changes: 3 additions & 1 deletion CopyCore/Sources/CopyCore/Storage/SmartSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions CopyCore/Tests/CopyCoreTests/ShelfQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
}
Loading