diff --git a/.gitignore b/.gitignore index 51c85cd3c..afcede106 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ xcuserdata/ Maccy.xcodeproj/project.xcworkspace/xcshareddata .idea .DS_Store -Maccy/.DS_Store +Maccy/.DS_Store \ No newline at end of file diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 44f0b2895..beb5f2dd3 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -140,6 +140,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func performStatusItemClick() { + let now = Date() + if let lastStatusItemClickAt, + now.timeIntervalSince(lastStatusItemClickAt) < Self.statusItemClickDebounceInterval { + return + } + lastStatusItemClickAt = now + if let event = NSApp.currentEvent { let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) @@ -154,14 +161,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - panel.toggle(height: AppState.shared.popup.height, at: .statusItem) + if panel.isPresented { + AppState.shared.popup.close() + } else { + AppState.shared.popup.open(height: AppState.shared.popup.height, at: .statusItem) + } } + private var pendingMenuIconTextSync = false + private var lastStatusItemClickAt: Date? + private static let statusItemClickDebounceInterval: TimeInterval = 0.35 + private func synchronizeMenuIconText() { + guard !pendingMenuIconTextSync else { return } + pendingMenuIconTextSync = true _ = withObservationTracking { AppState.shared.menuIconText } onChange: { DispatchQueue.main.async { + self.pendingMenuIconTextSync = false if Defaults[.showRecentCopyInMenuBar] { self.statusItem.button?.title = AppState.shared.menuIconText } diff --git a/Maccy/ApplicationImage.swift b/Maccy/ApplicationImage.swift index aba3fe05d..fa11f0e36 100644 --- a/Maccy/ApplicationImage.swift +++ b/Maccy/ApplicationImage.swift @@ -18,6 +18,15 @@ class ApplicationImage { self.image = image } + deinit { + invalidate() + } + + func invalidate() { + eventSource?.cancel() + eventSource = nil + } + var nsImage: NSImage { guard let bundleIdentifier else { return Self.fallbackImage diff --git a/Maccy/ApplicationImageCache.swift b/Maccy/ApplicationImageCache.swift index e3068e356..b0c21e7c9 100644 --- a/Maccy/ApplicationImageCache.swift +++ b/Maccy/ApplicationImageCache.swift @@ -1,10 +1,12 @@ class ApplicationImageCache { static let shared = ApplicationImageCache() + private static let maxCachedImages = 30 private let universalClipboardIdentifier: String = "com.apple.finder.Open-iCloudDrive" private let fallback = ApplicationImage(bundleIdentifier: nil) private var cache: [String: ApplicationImage] = [:] + private var recentlyUsed: [String] = [] func getImage(item: HistoryItem) -> ApplicationImage { guard let bundleIdentifier = bundleIdentifier(for: item) else { @@ -12,15 +14,63 @@ class ApplicationImageCache { } if let image = cache[bundleIdentifier] { + markUsed(bundleIdentifier) return image } let image = ApplicationImage(bundleIdentifier: bundleIdentifier) - cache[bundleIdentifier] = image + store(image, for: bundleIdentifier) return image } + // Fast path used during decorator init: only reads the `application` + // attribute so we don't hydrate the `contents` relationship for every item. + func getImage(bundleIdentifier: String?) -> ApplicationImage { + guard let bundleIdentifier else { + return fallback + } + + if let image = cache[bundleIdentifier] { + markUsed(bundleIdentifier) + return image + } + + let image = ApplicationImage(bundleIdentifier: bundleIdentifier) + store(image, for: bundleIdentifier) + + return image + } + + // Drop every cached `ApplicationImage`, releasing their NSImage raster + // caches and DispatchSource file watchers. Called from `History`'s idle + // flush so a closed-popup process doesn't keep app icons resident forever. + // Icons get recreated lazily next time a row asks for them. + func clear() { + cache.values.forEach { $0.invalidate() } + cache.removeAll() + recentlyUsed.removeAll() + } + + private func store(_ image: ApplicationImage, for bundleIdentifier: String) { + cache[bundleIdentifier] = image + markUsed(bundleIdentifier) + pruneIfNeeded() + } + + private func markUsed(_ bundleIdentifier: String) { + recentlyUsed.removeAll { $0 == bundleIdentifier } + recentlyUsed.append(bundleIdentifier) + } + + private func pruneIfNeeded() { + while recentlyUsed.count > Self.maxCachedImages, + let staleBundleIdentifier = recentlyUsed.first { + recentlyUsed.removeFirst() + cache.removeValue(forKey: staleBundleIdentifier)?.invalidate() + } + } + private func bundleIdentifier(for item: HistoryItem) -> String? { if item.universalClipboard { return universalClipboardIdentifier diff --git a/Maccy/Clipboard.swift b/Maccy/Clipboard.swift index 389afb9bd..2c0a6ebff 100644 --- a/Maccy/Clipboard.swift +++ b/Maccy/Clipboard.swift @@ -103,7 +103,7 @@ class Clipboard { sync() Task { - Notifier.notify(body: item.title, sound: .knock) + Notifier.notify(body: item.title.shortened(to: 160), sound: .knock) checkForChangesInPasteboard() } } @@ -208,7 +208,8 @@ class Clipboard { } types.forEach { type in - contents.append(HistoryItemContent(type: type.rawValue, value: item.data(forType: type))) + let value = compressImageDataIfNeeded(item.data(forType: type), type: type) + contents.append(HistoryItemContent(type: type.rawValue, value: value)) } }) @@ -225,6 +226,8 @@ class Clipboard { historyItem.application = sourceApp?.bundleIdentifier historyItem.title = historyItem.generateTitle() + historyItem.ensureContentFingerprint() + historyItem.ensureThumbnailData() onNewCopyHooks.forEach({ $0(historyItem) }) } @@ -299,6 +302,77 @@ class Clipboard { NSApp.hide(self) } + // Cap stored images so a single screenshot doesn't bloat the history store. + // Preview max is ~2048×1536; anything beyond 4096 on either side is pure waste. + private static let maxStoredImageDimension: CGFloat = 4096 + private static let imagePasteboardTypes: Set = [.png, .tiff, .jpeg, .heic] + + private func compressImageDataIfNeeded( + _ data: Data?, + type: NSPasteboard.PasteboardType + ) -> Data? { + guard let data, + Self.imagePasteboardTypes.contains(type), + let image = NSImage(data: data) else { + return data + } + + let size = image.size + let maxDim = Self.maxStoredImageDimension + guard size.width > maxDim || size.height > maxDim, + size.width > 0, size.height > 0 else { + return data + } + + let ratio = min(maxDim / size.width, maxDim / size.height) + let targetPixels = NSSize( + width: (size.width * ratio).rounded(), + height: (size.height * ratio).rounded() + ) + + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(targetPixels.width), + pixelsHigh: Int(targetPixels.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return data + } + rep.size = targetPixels + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { + return data + } + NSGraphicsContext.current = ctx + ctx.imageInterpolation = .high + image.draw(in: NSRect(origin: .zero, size: targetPixels)) + + let fileType: NSBitmapImageRep.FileType + let properties: [NSBitmapImageRep.PropertyKey: Any] + switch type { + case .png: + fileType = .png + properties = [:] + case .jpeg, .heic: + fileType = .jpeg + properties = [.compressionFactor: 0.85] + default: + fileType = .png + properties = [:] + } + + return rep.representation(using: fileType, properties: properties) ?? data + } + private func clearFormatting(_ contents: [HistoryItemContent]) -> [HistoryItemContent] { var newContents: [HistoryItemContent] = contents let stringContents = contents.filter { NSPasteboard.PasteboardType($0.type) == .string } diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 4a08a4bc2..06f1060d1 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -52,7 +52,7 @@ extension Defaults.Keys { static let searchVisibility = Key("searchVisibility", default: .always) static let showSpecialSymbols = Key("showSpecialSymbols", default: true) static let showTitle = Key("showTitle", default: true) - static let size = Key("historySize", default: 200) + static let size = Key("historySize", default: 3000) static let sortBy = Key("sortBy", default: .lastCopiedAt) static let suppressClearAlert = Key("suppressClearAlert", default: false) static let windowSize = Key("windowSize", default: NSSize(width: 450, height: 800)) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index c6ba17150..b4841cd73 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -72,9 +72,14 @@ class FloatingPanel: NSPanel, NSWindowDelegate { } func open(height: CGFloat, at popupPosition: PopupPosition = Defaults[.popupPosition]) { + if AppState.shared.navigator.leadHistoryItem == nil && !AppState.shared.navigator.pasteStackSelected { + AppState.shared.preview.closeImmediately() + } + let size = Defaults[.windowSize] setContentSize(NSSize(width: min(frame.width, size.width), height: min(height, size.height))) setFrameOrigin(popupPosition.origin(size: frame.size, statusBarButton: statusBarButton)) + AppState.shared.preview.lockPlacement(window: self) orderFrontRegardless() makeKey() isPresented = true @@ -100,6 +105,7 @@ class FloatingPanel: NSPanel, NSWindowDelegate { func determinePreviewPlacement() { let preview = AppState.shared.preview + guard !preview.placementLocked else { return } guard !preview.state.isOpen else { return } let newSize = preview.computeSizeWithPreview(frame.size, state: .open) preview.placement = preview.computePlacement(window: self, for: newSize) @@ -179,10 +185,7 @@ class FloatingPanel: NSPanel, NSWindowDelegate { func windowDidBecomeKey(_ notification: Notification) { AppState.shared.preview.enableAutoOpen() - - if AppState.shared.navigator.leadHistoryItem != nil { - AppState.shared.preview.startAutoOpen() - } + AppState.shared.preview.startAutoOpenForCurrentSelection() } func windowDidResignKey(_ notification: Notification) { @@ -201,6 +204,7 @@ class FloatingPanel: NSPanel, NSWindowDelegate { override func close() { super.close() AppState.shared.preview.state = .closed + AppState.shared.preview.unlockPlacement() isPresented = false statusBarButton?.isHighlighted = false onClose() diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 08e7b726a..3ba5703fe 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -1,4 +1,5 @@ import AppKit +import CryptoKit import Defaults import Sauce import SwiftData @@ -65,6 +66,13 @@ class HistoryItem { var numberOfCopies: Int = 1 var pin: String? var title = "" + var contentFingerprint: String? + + // Pre-rendered thumbnail bytes for image items. Small JPEG (~5-10 KB) used by + // the list UI so browsing never has to fault in the full `contents` + // relationship. Nil for text/file items and for image items that pre-date + // this migration (backfilled lazily — see `History.backfillThumbnails`). + var thumbnailData: Data? @Relationship(deleteRule: .cascade, inverse: \HistoryItemContent.item) var contents: [HistoryItemContent] = [] @@ -85,6 +93,103 @@ class HistoryItem { } } + func ensureContentFingerprint() { + guard contentFingerprint == nil else { return } + contentFingerprint = Self.makeContentFingerprint(from: contents) + } + + static func makeContentFingerprint(from contents: [HistoryItemContent]) -> String? { + let stableContents = contents + .filter { !transientTypes.contains($0.type) } + .sorted { lhs, rhs in + if lhs.type == rhs.type { + return (lhs.value ?? Data()).lexicographicallyPrecedes(rhs.value ?? Data()) + } + return lhs.type < rhs.type + } + + guard !stableContents.isEmpty else { return nil } + + var bytes = Data() + for content in stableContents { + bytes.append(Data(content.type.utf8)) + bytes.append(0) + if let value = content.value { + bytes.append(Data(String(value.count).utf8)) + bytes.append(0) + bytes.append(value) + } else { + bytes.append(Data("-1".utf8)) + bytes.append(0) + } + bytes.append(0xff) + } + + return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + } + + // 2x retina capacity for a 340x40 row tile, JPEG-encoded. Keeps each + // thumbnail under ~10 KB so the entire history's worth fits in a few MB. + static let thumbnailMaxPixelSize = NSSize(width: 680, height: 80) + static let thumbnailJPEGQuality: CGFloat = 0.7 + + // Synchronous; use only off the main thread or for fresh captures where the + // image is already decoded. Returns nil if `data` doesn't decode or is + // smaller than the target (no point in re-encoding). + static func makeThumbnailData(from data: Data) -> Data? { + guard let source = NSImage(data: data) else { return nil } + let sourceSize = source.size + guard sourceSize.width > 0, sourceSize.height > 0 else { return nil } + + let target = HistoryItem.thumbnailMaxPixelSize + let ratio = min(target.width / sourceSize.width, target.height / sourceSize.height) + let pixelSize: NSSize + if ratio >= 1 { + pixelSize = sourceSize + } else { + pixelSize = NSSize( + width: max(1, (sourceSize.width * ratio).rounded()), + height: max(1, (sourceSize.height * ratio).rounded()) + ) + } + + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(pixelSize.width), + pixelsHigh: Int(pixelSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return nil + } + rep.size = pixelSize + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return nil } + NSGraphicsContext.current = ctx + ctx.imageInterpolation = .high + source.draw(in: NSRect(origin: .zero, size: pixelSize), + from: .zero, operation: .copy, fraction: 1) + + return rep.representation( + using: .jpeg, + properties: [.compressionFactor: HistoryItem.thumbnailJPEGQuality] + ) + } + + func ensureThumbnailData() { + guard thumbnailData == nil else { return } + guard let data = imageData else { return } + thumbnailData = HistoryItem.makeThumbnailData(from: data) + } + func generateTitle() -> String { guard image == nil else { Task { @@ -129,6 +234,30 @@ class HistoryItem { } } + func previewableText(upTo maxLength: Int) -> String { + if !fileURLs.isEmpty { + return fileURLs + .compactMap { $0.absoluteString.removingPercentEncoding } + .joined(separator: "\n") + .shortened(to: maxLength) + } else if let text = text(upTo: maxLength), !text.isEmpty { + return text + } else if let rtf = rtf, !rtf.string.isEmpty { + return rtf.string.shortened(to: maxLength) + } else if let html = html, !html.string.isEmpty { + return html.string.shortened(to: maxLength) + } else { + return title.shortened(to: maxLength) + } + } + + func hasPreviewableText(after maxLength: Int) -> Bool { + if let textData = contentData([.string]) { + return textData.count > maxLength + } + return previewableText.count > maxLength + } + var fileURLs: [URL] { guard !universalClipboardText else { return [] @@ -157,6 +286,10 @@ class HistoryItem { return data } + var hasImageDataType: Bool { + containsContentType([.tiff, .png, .jpeg, .heic]) + } + var image: NSImage? { guard let data = imageData else { return nil @@ -182,6 +315,20 @@ class HistoryItem { return String(data: data, encoding: .utf8) } + var textByteSize: Int { + return contentData([.string])?.count ?? 0 + } + + func text(upTo maxLength: Int) -> String? { + guard let data = contentData([.string]) else { + return nil + } + + let byteLimit = min(data.count, maxLength * 4) + return String(data: data.prefix(byteLimit), encoding: .utf8)? + .shortened(to: maxLength) + } + var modified: Int? { guard let data = contentData([.modified]), let modified = String(data: data, encoding: .utf8) else { @@ -207,6 +354,12 @@ class HistoryItem { return content?.value } + private func containsContentType(_ types: [NSPasteboard.PasteboardType]) -> Bool { + return contents.contains { content in + types.contains(NSPasteboard.PasteboardType(content.type)) + } + } + private func allContentData(_ types: [NSPasteboard.PasteboardType]) -> [Data] { return contents .filter { types.contains(NSPasteboard.PasteboardType($0.type)) } diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index f290418df..93288eaff 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -26,7 +26,10 @@ class AppState: Sendable { } var menuIconText: String { - var title = history.unpinnedItems.first?.text.shortened(to: 100) + // Use the cached title (stored attribute on HistoryItem) instead of + // `decorator.text`, which reads the `contents` relationship and forces + // SwiftData to hydrate image/RTF/HTML blobs. + var title = history.unpinnedItems.first?.title.shortened(to: 100) .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" title.unicodeScalars.removeAll(where: CharacterSet.newlines.contains) return title.shortened(to: 20) @@ -34,6 +37,7 @@ class AppState: Sendable { private let about = About() private var settingsWindowController: SettingsWindowController? + private var settingsWindowCloseObserver: NSObjectProtocol? init(history: History, footer: Footer) { self.history = history @@ -108,7 +112,7 @@ class AppState: Sendable { @MainActor func openPreferences() { // swiftlint:disable:this function_body_length if settingsWindowController == nil { - settingsWindowController = SettingsWindowController( + let controller = SettingsWindowController( panes: [ Settings.Pane( identifier: Settings.PaneIdentifier.general, @@ -156,6 +160,23 @@ class AppState: Sendable { } ] ) + settingsWindowController = controller + + if let window = controller.window { + settingsWindowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self, weak controller] _ in + guard let self, self.settingsWindowController === controller else { return } + + if let observer = self.settingsWindowCloseObserver { + NotificationCenter.default.removeObserver(observer) + } + self.settingsWindowCloseObserver = nil + self.settingsWindowController = nil + } + } } settingsWindowController?.show() settingsWindowController?.window?.orderFrontRegardless() diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index e5804ad19..99fb5630c 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -21,16 +21,52 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var searchQuery: String = "" { didSet { + searchRevision += 1 + let query = searchQuery + let revision = searchRevision + searchTask?.cancel() + searchTask = nil + if query.isEmpty { + Task { @MainActor in + startThumbnailBackfillIfNeeded() + } + } else { + Task { @MainActor in + await stopThumbnailBackfill() + } + } throttler.throttle { [self] in - updateItems(search.search(string: searchQuery, within: all)) + guard revision == searchRevision else { return } - if searchQuery.isEmpty { + if query.isEmpty { + // Restore the paginated browse view. + updateItems(all.map { Search.SearchResult(object: $0) }) AppState.shared.navigator.select(item: unpinnedItems.first) + AppState.shared.popup.needsResize = true } else { + // Show matches from the retained window immediately so typing does + // not feel blocked by the full-history fetch below. + updateItems(search.search(string: query, within: all)) AppState.shared.navigator.highlightFirst() + AppState.shared.popup.needsResize = true + + // Search needs to span ALL items, not just the loaded page. + // Fetch the rest in the background, then replace results only if + // this query is still current. + searchTask = Task { @MainActor in + defer { + if revision == searchRevision { + searchTask = nil + } + } + let searchItems = await fullHistoryDecoratorsForSearch() + guard !Task.isCancelled else { return } + guard revision == searchRevision, searchQuery == query else { return } + await updateItems(search.search(string: query, within: searchItems), query: query) + AppState.shared.navigator.highlightFirst() + AppState.shared.popup.needsResize = true + } } - - AppState.shared.popup.needsResize = true } } } @@ -55,6 +91,10 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length private let search = Search() private let sorter = Sorter() private let throttler = Throttler(minimumDelay: 0.2) + @ObservationIgnored + private var searchRevision = 0 + @ObservationIgnored + private var searchTask: Task? @ObservationIgnored private var sessionLog: [Int: HistoryItem] = [:] @@ -65,6 +105,49 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @ObservationIgnored var all: [HistoryItemDecorator] = [] + // Soft flush: drop decorators + reset SwiftData context after the popup + // has been closed for a while so the OS can release blob memory loaded + // during browsing/preview. Re-fetched on demand by `load()`. + @ObservationIgnored + private var idleFlushTask: Task? + private static let idleFlushDelay: Duration = .seconds(30) + @ObservationIgnored + private(set) var needsReloadAfterIdleFlush = false + + // Background thumbnail backfill — only runs while the popup is open. + @ObservationIgnored + private var backfillTask: Task? + + // Pagination: only the first `pageSize` unpinned items are loaded into + // memory at startup. More are fetched on demand as the user scrolls near + // the end. Pinned items are always all loaded (small set, capped by the + // pin-shortcut alphabet at ~22). + private static let pageSize = 50 + private static var maxUnpinnedWindowSize: Int { + max(pageSize, Defaults[.size]) + } + private static let pruneUnpinnedBatchSize = 50 + // Trigger loadMore when this many items from the end becomes visible. + private static let pageLoadAheadCount = 20 + @ObservationIgnored + private var hasMoreUnpinnedItems = false + @ObservationIgnored + private var isLoadingMore = false + // True once we've fetched everything (only happens when search forces a + // full load, or pagination reaches the end naturally). + @ObservationIgnored + private var allUnpinnedFetched = false + // Cached counts so `itemDidAppear` and `appendUnpinned` don't have to + // rescan `all` (which is O(n) and gets called on every scroll event). + @ObservationIgnored + private var loadedPinnedCount = 0 + @ObservationIgnored + private var loadedUnpinnedCount = 0 + @ObservationIgnored + private var loadedUnpinnedStartIndex = 0 + @ObservationIgnored + private var totalUnpinnedCount = 0 + init() { Task { for await _ in Defaults.updates(.pasteByDefault, initial: false) { @@ -101,12 +184,95 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } + @MainActor + func scheduleIdleFlush() { + idleFlushTask?.cancel() + idleFlushTask = Task { [weak self] in + try? await Task.sleep(for: Self.idleFlushDelay) + guard !Task.isCancelled else { return } + await self?.flushIfIdle() + } + } + + @MainActor + func cancelIdleFlush() { + idleFlushTask?.cancel() + idleFlushTask = nil + } + + @MainActor + private func flushIfIdle() async { + guard AppState.shared.popup.isClosed() else { + logger.info("Idle flush: skipped (popup still open)") + return + } + guard pasteStack == nil else { + logger.info("Idle flush: skipped (paste stack in progress)") + return + } + guard !all.isEmpty else { + logger.info("Idle flush: skipped (already empty)") + return + } + + let beforeCount = all.count + logger.info("Idle flush: dropping \(beforeCount) decorators and resetting SwiftData context") + + // Cancel and AWAIT any in-flight backfill so its actor's context fully + // releases before we recreate the container. Without the await, the + // backfill could be mid-sleep and still pinning blob memory. + await stopThumbnailBackfill() + + AppState.shared.navigator.selectWithoutScrolling(item: nil) + items = [] + all = [] + sessionLog = [:] + // Drop every cached app icon + its file watcher. Saves ~30-60 MB + // depending on how many distinct source apps the session touched. + ApplicationImageCache.shared.clear() + + // Give SwiftUI/AppKit/ARC a turn to release row and icon objects before + // creating a fresh SwiftData container, reducing short-lived idle peaks. + await Task.yield() + guard AppState.shared.popup.isClosed() else { + logger.info("Idle flush: skipped container reset (popup reopened)") + return + } + + Storage.shared.recreateContainer() + needsReloadAfterIdleFlush = true + logger.info("Idle flush: complete, decorators will reload on next popup open") + } + @MainActor func load() async throws { - let descriptor = FetchDescriptor() - let results = try Storage.shared.context.fetch(descriptor) - all = sorter.sort(results).map { HistoryItemDecorator($0) } + needsReloadAfterIdleFlush = false + // Pinned items: always load all (small set, max ~22 due to single-letter shortcuts). + var pinnedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.pin != nil }, + sortBy: sortDescriptors() + ) + pinnedDescriptor.fetchLimit = 64 + let pinnedItems = (try? Storage.shared.context.fetch(pinnedDescriptor)) ?? [] + + // Unpinned items: just the first page. + var unpinnedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.pin == nil }, + sortBy: sortDescriptors() + ) + unpinnedDescriptor.fetchLimit = Self.pageSize + let unpinnedResults = (try? Storage.shared.context.fetch(unpinnedDescriptor)) ?? [] + + let pinnedDecorators = pinnedItems.map { HistoryItemDecorator($0) } + let unpinnedDecorators = unpinnedResults.map { HistoryItemDecorator($0) } + all = combineDecorators(pinned: pinnedDecorators, unpinned: unpinnedDecorators) items = all + loadedPinnedCount = pinnedDecorators.count + loadedUnpinnedCount = unpinnedDecorators.count + loadedUnpinnedStartIndex = 0 + let countDescriptor = FetchDescriptor(predicate: #Predicate { $0.pin == nil }) + totalUnpinnedCount = (try? Storage.shared.context.fetchCount(countDescriptor)) ?? unpinnedResults.count + updatePaginationFlags() limitHistorySize(to: Defaults[.size]) @@ -117,12 +283,339 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } + @MainActor + func loadForPopupOpenIfNeeded() async { + guard all.isEmpty || needsReloadAfterIdleFlush else { return } + + try? await load() + guard !searchQuery.isEmpty else { return } + + let query = searchQuery + let revision = searchRevision + let searchItems = await fullHistoryDecoratorsForSearch() + guard !Task.isCancelled, revision == searchRevision, searchQuery == query else { return } + await updateItems(search.search(string: query, within: searchItems), query: query) + AppState.shared.navigator.highlightFirst() + AppState.shared.popup.needsResize = true + } + + // SortDescriptor matching the user's `Defaults[.sortBy]` choice. Used by + // every paginated fetch so SQLite returns rows in the right order without + // us having to materialize and re-sort. + private func sortDescriptors() -> [SortDescriptor] { + switch Defaults[.sortBy] { + case .firstCopiedAt: + return [SortDescriptor(\.firstCopiedAt, order: .reverse)] + case .numberOfCopies: + return [SortDescriptor(\.numberOfCopies, order: .reverse)] + default: + return [SortDescriptor(\.lastCopiedAt, order: .reverse)] + } + } + + // Pinned-vs-unpinned ordering matches `Defaults[.pinTo]`; within each + // group the SQL fetch already gave us the right primary sort. + private func combineDecorators( + pinned: [HistoryItemDecorator], + unpinned: [HistoryItemDecorator] + ) -> [HistoryItemDecorator] { + if Defaults[.pinTo] == .bottom { + return unpinned + pinned + } else { + return pinned + unpinned + } + } + + // Called from `HistoryItemView.onAppear` with the row's index in + // `unpinnedItems`. O(1) — no array filter, no search. Fast enough to fire + // on every scrolling row without affecting frame rate. + @MainActor + func itemDidAppear(at unpinnedIndex: Int) { + guard searchQuery.isEmpty else { return } + guard !isLoadingMore else { return } + + let absoluteIndex = loadedUnpinnedStartIndex + unpinnedIndex + let loadedEndIndex = loadedUnpinnedStartIndex + loadedUnpinnedCount + + if loadedUnpinnedStartIndex > 0, + unpinnedIndex <= Self.pageLoadAheadCount { + Task { @MainActor in + await loadPreviousUnpinnedItems() + } + return + } + + guard hasMoreUnpinnedItems else { return } + guard absoluteIndex >= loadedEndIndex - Self.pageLoadAheadCount else { return } + Task { @MainActor in + await loadMoreUnpinnedItems() + } + } + + @MainActor + private func loadMoreUnpinnedItems() async { + guard hasMoreUnpinnedItems, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.pin == nil }, + sortBy: sortDescriptors() + ) + descriptor.fetchOffset = loadedUnpinnedStartIndex + loadedUnpinnedCount + descriptor.fetchLimit = Self.pageSize + + guard let next = try? Storage.shared.context.fetch(descriptor), !next.isEmpty else { + hasMoreUnpinnedItems = false + allUnpinnedFetched = true + return + } + + // Build decorators in chunks with yields so SwiftUI can update scroll + // position between batches instead of stalling on the whole page. + var newDecorators: [HistoryItemDecorator] = [] + newDecorators.reserveCapacity(next.count) + for (idx, item) in next.enumerated() { + newDecorators.append(HistoryItemDecorator(item)) + if idx > 0 && idx % 25 == 0 { + await Task.yield() + } + } + appendUnpinned(newDecorators) + pruneUnpinnedWindowIfNeeded(fromTop: true) + updatePaginationFlags() + } + + @MainActor + private func loadPreviousUnpinnedItems() async { + guard loadedUnpinnedStartIndex > 0, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + let offset = max(0, loadedUnpinnedStartIndex - Self.pageSize) + let limit = loadedUnpinnedStartIndex - offset + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.pin == nil }, + sortBy: sortDescriptors() + ) + descriptor.fetchOffset = offset + descriptor.fetchLimit = limit + + guard let previous = try? Storage.shared.context.fetch(descriptor), !previous.isEmpty else { + loadedUnpinnedStartIndex = 0 + updatePaginationFlags() + return + } + + var newDecorators: [HistoryItemDecorator] = [] + newDecorators.reserveCapacity(previous.count) + for (idx, item) in previous.enumerated() { + newDecorators.append(HistoryItemDecorator(item)) + if idx > 0 && idx % 25 == 0 { + await Task.yield() + } + } + prependUnpinned(newDecorators) + loadedUnpinnedStartIndex = offset + pruneUnpinnedWindowIfNeeded(fromTop: false) + updatePaginationFlags() + } + + // For use by the search path: load a temporary full-history corpus so search + // can match outside the current paginated browse window without replacing it. + @MainActor + private func fullHistoryDecoratorsForSearch() async -> [HistoryItemDecorator] { + let container = Storage.shared.container + let sortBy = Defaults[.sortBy] + let pinTo = Defaults[.pinTo] + let ids = await SearchHistoryLoader(modelContainer: container) + .sortedItemIDs(sortBy: sortBy, pinTo: pinTo) + guard !Task.isCancelled, !ids.isEmpty else { return [] } + + return await decorators(from: ids) + } + + @MainActor + private func decorators(from results: [HistoryItem]) async -> [HistoryItemDecorator] { + var decorators: [HistoryItemDecorator] = [] + decorators.reserveCapacity(results.count) + for (idx, item) in results.enumerated() { + decorators.append(HistoryItemDecorator(item)) + if idx > 0 && idx % 100 == 0 { + await Task.yield() + } + } + return decorators + } + + @MainActor + private func decorators(from ids: [PersistentIdentifier]) async -> [HistoryItemDecorator] { + var decorators: [HistoryItemDecorator] = [] + decorators.reserveCapacity(ids.count) + + for (idx, id) in ids.enumerated() { + guard !Task.isCancelled else { break } + if let item = Storage.shared.context.model(for: id) as? HistoryItem { + decorators.append(HistoryItemDecorator(item)) + } + if idx > 0 && idx % 50 == 0 { + await Task.yield() + } + } + + return decorators + } + + @MainActor + private func appendUnpinned(_ newDecorators: [HistoryItemDecorator]) { + // Direct O(k) insert — no full array filtering. New unpinned rows always + // go after the existing unpinned section, so the position depends only + // on `pinTo` and our cached counts. + if Defaults[.pinTo] == .bottom { + // Layout: [unpinned... | pinned...]. Insert at end of unpinned section. + all.insert(contentsOf: newDecorators, at: loadedUnpinnedCount) + } else { + // Layout: [pinned... | unpinned...]. Append to the end. + all.append(contentsOf: newDecorators) + } + loadedUnpinnedCount += newDecorators.count + + if searchQuery.isEmpty { + items = all + } + // Skip `updateUnpinnedShortcuts` — the shortcut-eligible rows (first 9 + // unpinned) didn't change, only items further down were appended. + } + + @MainActor + private func prependUnpinned(_ newDecorators: [HistoryItemDecorator]) { + if Defaults[.pinTo] == .bottom { + all.insert(contentsOf: newDecorators, at: 0) + } else { + all.insert(contentsOf: newDecorators, at: loadedPinnedCount) + } + loadedUnpinnedCount += newDecorators.count + + if searchQuery.isEmpty { + items = all + } + } + + @MainActor + private func pruneUnpinnedWindowIfNeeded(fromTop: Bool) { + guard searchQuery.isEmpty else { return } + guard loadedUnpinnedCount > Self.maxUnpinnedWindowSize else { return } + + let pruneCount = min( + Self.pruneUnpinnedBatchSize, + loadedUnpinnedCount - Self.pageSize + ) + guard pruneCount > 0 else { return } + + let removeRange: Range + if fromTop { + if Defaults[.pinTo] == .bottom { + removeRange = 0..= totalUnpinnedCount + } + + // Backfill is gated on popup state: only runs while the user has the popup + // open. That way idle memory stays low — the moment the popup closes, + // backfill stops and `flushIfIdle` can release everything cleanly. + @MainActor + func startThumbnailBackfillIfNeeded() { + guard backfillTask == nil else { return } + let container = Storage.shared.container + backfillTask = Task.detached(priority: .background) { + while !Task.isCancelled { + // Scoped block — actor + its context die when this exits, so the + // batch's hydrated `contents` blobs drop between batches. + let count = await { + let actor = ThumbnailBackfiller(modelContainer: container) + return await actor.backfillBatch(limit: 10) + }() + if count == 0 { break } + try? await Task.sleep(for: .milliseconds(1500)) + } + } + } + + @MainActor + func stopThumbnailBackfill() async { + guard let task = backfillTask else { return } + task.cancel() + _ = await task.value + backfillTask = nil + } + @MainActor private func limitHistorySize(to maxSize: Int) { - let unpinned = all.filter(\.isUnpinned) - if unpinned.count >= maxSize { - unpinned[maxSize...].forEach(delete) + // DB-based now: `all` may only hold the first page of unpinned items, + // so filtering it locally would never trigger a prune for paginated + // installs. Fetch the count from SQLite and delete the oldest excess. + let countDescriptor = FetchDescriptor(predicate: #Predicate { $0.pin == nil }) + guard let count = try? Storage.shared.context.fetchCount(countDescriptor), + count > maxSize else { + return } + + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.pin == nil }, + sortBy: [SortDescriptor(\.lastCopiedAt)] // ascending: oldest first + ) + descriptor.fetchLimit = count - maxSize + guard let toDelete = try? Storage.shared.context.fetch(descriptor) else { return } + + for stale in toDelete { + // Drop in-memory references too if they happen to be on the loaded page. + all.removeAll { $0.item == stale } + items.removeAll { $0.item == stale } + Storage.shared.context.delete(stale) + } + Storage.shared.context.processPendingChanges() + try? Storage.shared.context.save() } @MainActor @@ -147,6 +640,8 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length if let existingHistoryItem = findSimilarItem(item) { if isModified(item) == nil { item.contents = existingHistoryItem.contents + item.contentFingerprint = existingHistoryItem.contentFingerprint + item.thumbnailData = existingHistoryItem.thumbnailData } item.firstCopiedAt = existingHistoryItem.firstCopiedAt item.numberOfCopies += existingHistoryItem.numberOfCopies @@ -445,17 +940,26 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor private func findSimilarItem(_ item: HistoryItem) -> HistoryItem? { - let descriptor = FetchDescriptor() - if let all = try? Storage.shared.context.fetch(descriptor) { - let duplicates = all.filter({ $0 == item || $0.supersedes(item) }) - if duplicates.count > 1 { - return duplicates.first(where: { $0 != item }) - } else { - return isModified(item) + if let modifiedItem = isModified(item) { + return modifiedItem + } + + item.ensureContentFingerprint() + + if let fingerprint = item.contentFingerprint { + var fingerprintDescriptor = FetchDescriptor( + predicate: #Predicate { $0.contentFingerprint == fingerprint }, + sortBy: sortDescriptors() + ) + fingerprintDescriptor.fetchLimit = 2 + + if let duplicate = try? Storage.shared.context.fetch(fingerprintDescriptor) + .first(where: { $0 != item }) { + return duplicate } } - return item + return nil } private func isModified(_ item: HistoryItem) -> HistoryItem? { @@ -466,10 +970,11 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length return nil } - private func updateItems(_ newItems: [Search.SearchResult]) { + private func updateItems(_ newItems: [Search.SearchResult], query: String? = nil) { + let query = query ?? searchQuery items = newItems.map { result in let item = result.object - item.highlight(searchQuery, result.ranges) + item.highlight(query, result.ranges) return item } @@ -477,6 +982,25 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length updateUnpinnedShortcuts() } + @MainActor + private func updateItems(_ newItems: [Search.SearchResult], query: String) async { + var updatedItems: [HistoryItemDecorator] = [] + updatedItems.reserveCapacity(newItems.count) + + for (idx, result) in newItems.enumerated() { + let item = result.object + item.highlight(query, result.ranges) + updatedItems.append(item) + + if idx > 0 && idx % 100 == 0 { + await Task.yield() + } + } + + items = updatedItems + updateUnpinnedShortcuts() + } + private func updateShortcuts() { for item in pinnedItems { if let pin = item.item.pin { diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 573e829d1..07f5d92ba 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -6,6 +6,9 @@ import Sauce @Observable class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { + private static let listTitleLimit = 160 + static let previewTextPageSize = 500 + static func == (lhs: HistoryItemDecorator, rhs: HistoryItemDecorator) -> Bool { return lhs.id == rhs.id } @@ -16,6 +19,7 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { let id = UUID() var title: String = "" + var displayTitle: String = "" var attributedTitle: AttributedString? var isVisible: Bool = true @@ -39,69 +43,191 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { return url.deletingPathExtension().lastPathComponent } - var hasImage: Bool { item.image != nil } + // Type-only check. Do not touch `value` here; preview layout can be built + // during hover/selection and blob hydration would block scrolling. + var hasImage: Bool { item.hasImageDataType } var previewImageGenerationTask: Task<(), Error>? - var thumbnailImageGenerationTask: Task<(), Error>? + var thumbnailImageGenerationTask: Task? var previewImage: NSImage? var thumbnailImage: NSImage? var applicationImage: ApplicationImage - // 10k characters seems to be more than enough on large displays - var text: String { item.previewableText.shortened(to: 10_000) } + var dataSize: String { + let bytes = item.contents.reduce(0) { total, content in + total + (content.value?.count ?? 0) + } + return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } var isPinned: Bool { item.pin != nil } var isUnpinned: Bool { item.pin == nil } + var shouldAutoOpenPreview: Bool { + hasImage || item.title.count <= Self.previewTextPageSize + } func hash(into hasher: inout Hasher) { - // We need to hash title and attributedTitle, so SwiftUI knows it needs to update the view if they chage hasher.combine(id) - hasher.combine(title) + hasher.combine(displayTitle) hasher.combine(attributedTitle) } private(set) var item: HistoryItem + private var applicationImageRefreshed = false + @ObservationIgnored + private var debouncedSelectionLoadTask: Task? + // Observation handlers are heavy to register at scale (100 items per + // pagination batch × 2 observers each). Defer setup until the row first + // appears — most items in the loaded set never become visible at all. + @ObservationIgnored + private var observationStarted = false + init(_ item: HistoryItem, shortcuts: [KeyShortcut] = []) { self.item = item self.shortcuts = shortcuts self.title = item.title - self.applicationImage = ApplicationImageCache.shared.getImage(item: item) + self.displayTitle = Self.listTitle(from: item.title) + self.applicationImage = ApplicationImageCache.shared.getImage(bundleIdentifier: item.application) + // synchronizeItemPin / synchronizeItemTitle are deferred to first + // .onAppear via `startObservationIfNeeded()` — they're observer setup, + // not initial state, and registering them eagerly was the bulk of + // pagination's per-page cost. + } + @MainActor + func startObservationIfNeeded() { + guard !observationStarted else { return } + observationStarted = true synchronizeItemPin() synchronizeItemTitle() } @MainActor - func ensureThumbnailImage() { - guard item.image != nil else { - return - } - guard thumbnailImage == nil else { - return - } - guard thumbnailImageGenerationTask == nil else { - return + func cancelSelectionLoad() { + debouncedSelectionLoadTask?.cancel() + debouncedSelectionLoadTask = nil + } + + @MainActor + func refreshApplicationImageIfNeeded() { + guard !applicationImageRefreshed else { return } + applicationImageRefreshed = true + let refreshed = ApplicationImageCache.shared.getImage(item: item) + if refreshed !== applicationImage { + applicationImage = refreshed } - thumbnailImageGenerationTask = Task { [weak self] in - self?.generateThumbnailImage() + } + + @MainActor + func previewText(upTo maxLength: Int) -> String { + return item.previewableText(upTo: maxLength) + } + + @MainActor + func hasMorePreviewText(after maxLength: Int) -> Bool { + return item.hasPreviewableText(after: maxLength) + } + + // Fast path only. Reads the pre-rendered `thumbnailData` attribute and + // decodes it off the scroll path so row appearance does not block input. + // Treats an empty `Data()` as a "known to have no thumbnail" sentinel + // (text/file items get marked this way during backfill). + @MainActor + func ensureThumbnailImage() { + guard thumbnailImage == nil else { return } + guard thumbnailImageGenerationTask == nil else { return } + guard let thumbBytes = item.thumbnailData, !thumbBytes.isEmpty else { return } + + thumbnailImageGenerationTask = Task.detached(priority: .utility) { [weak self] in + let image = NSImage(data: thumbBytes) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self else { return } + self.thumbnailImage = image + self.thumbnailImageGenerationTask = nil + } } } + // Preview is the explicit "show me the data" path — always loads the full + // blob. Opportunistically backfills `thumbnailData` while we have the + // decoded source in hand, so future browse hits the fast path. @MainActor func ensurePreviewImage() { - guard item.image != nil else { - return - } - guard previewImage == nil else { - return + guard previewImage == nil else { return } + guard previewImageGenerationTask == nil else { return } + guard let data = item.imageData else { return } + let targetSize = HistoryItemDecorator.previewImageSize + let captureItem = item + let needsThumbnailBackfill = item.thumbnailData == nil + previewImageGenerationTask = Task.detached(priority: .userInitiated) { [weak self] in + let resized = Self.rasterizedImage(from: data, targetSize: targetSize) + let thumbBytes = needsThumbnailBackfill ? HistoryItem.makeThumbnailData(from: data) : nil + let decodedThumb = thumbBytes.flatMap { NSImage(data: $0) } + await MainActor.run { + guard let self else { return } + if let thumbBytes { + captureItem.thumbnailData = thumbBytes + try? Storage.shared.context.save() + } + self.previewImage = resized + if self.thumbnailImage == nil, let decodedThumb { + self.thumbnailImage = decodedThumb + } + self.previewImageGenerationTask = nil + } } - guard previewImageGenerationTask == nil else { - return + } + + // Decodes and rasterizes off-main. Returns a bitmap-backed NSImage so that + // SwiftUI's render pass is a cheap blit, not a re-decode. + nonisolated private static func rasterizedImage( + from data: Data, + targetSize: NSSize + ) -> NSImage? { + guard let source = NSImage(data: data) else { return nil } + let sourceSize = source.size + guard sourceSize.width > 0, sourceSize.height > 0 else { return nil } + + let ratio = min(targetSize.width / sourceSize.width, targetSize.height / sourceSize.height) + if ratio >= 1 { + return source } - previewImageGenerationTask = Task { [weak self] in - self?.generatePreviewImage() + let pixelSize = NSSize( + width: max(1, (sourceSize.width * ratio).rounded()), + height: max(1, (sourceSize.height * ratio).rounded()) + ) + + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(pixelSize.width), + pixelsHigh: Int(pixelSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + return source } + rep.size = pixelSize + + NSGraphicsContext.saveGraphicsState() + defer { NSGraphicsContext.restoreGraphicsState() } + guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return source } + NSGraphicsContext.current = ctx + ctx.imageInterpolation = .high + source.draw(in: NSRect(origin: .zero, size: pixelSize), + from: .zero, operation: .copy, fraction: 1) + + let rasterized = NSImage(size: pixelSize) + rasterized.addRepresentation(rep) + return rasterized } @MainActor @@ -122,6 +248,16 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { previewImage?.recache() thumbnailImage = nil previewImage = nil + thumbnailImageGenerationTask = nil + previewImageGenerationTask = nil + } + + @MainActor + func cleanupPreviewImage() { + previewImageGenerationTask?.cancel() + previewImage?.recache() + previewImage = nil + previewImageGenerationTask = nil } @MainActor @@ -148,14 +284,23 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { func highlight(_ query: String, _ ranges: [Range]) { guard !query.isEmpty, !title.isEmpty else { + displayTitle = Self.listTitle(from: title) attributedTitle = nil return } - var attributedString = AttributedString(title.shortened(to: 500)) + let visibleRange = Self.visibleRange(in: title, around: ranges.first) + displayTitle = Self.listTitle(from: title, range: visibleRange) + var attributedString = AttributedString(displayTitle) for range in ranges { - if let lowerBound = AttributedString.Index(range.lowerBound, within: attributedString), - let upperBound = AttributedString.Index(range.upperBound, within: attributedString) { + guard let clippedRange = Self.clippedRange(range, to: visibleRange) else { continue } + let lowerOffset = title.distance(from: visibleRange.lowerBound, to: clippedRange.lowerBound) + let upperOffset = title.distance(from: visibleRange.lowerBound, to: clippedRange.upperBound) + let displayLower = displayTitle.index(displayTitle.startIndex, offsetBy: lowerOffset) + let displayUpper = displayTitle.index(displayTitle.startIndex, offsetBy: upperOffset) + + if let lowerBound = AttributedString.Index(displayLower, within: attributedString), + let upperBound = AttributedString.Index(displayUpper, within: attributedString) { switch Defaults[.highlightMatch] { case .bold: attributedString[lowerBound.. String { + return listTitle(from: title, range: visibleRange(in: title, around: nil)) + } + + private static func listTitle(from title: String, range: Range) -> String { + guard title.count > listTitleLimit else { return title } + + var excerpt = String(title[range]) + if range.lowerBound > title.startIndex { + excerpt = "..." + excerpt + } + if range.upperBound < title.endIndex { + excerpt += "..." + } + return excerpt + } + + private static func visibleRange( + in title: String, + around matchRange: Range? + ) -> Range { + guard title.count > listTitleLimit else { return title.startIndex.., + to visibleRange: Range + ) -> Range? { + let lower = max(range.lowerBound, visibleRange.lowerBound) + let upper = min(range.upperBound, visibleRange.upperBound) + guard lower < upper else { return nil } + return lower.. 0 ? height : fallbackHeight + + if popupPosition == .statusItem { + AppState.shared.navigator.selectWithoutScrolling(item: nil) + suppressesHoverSelection = true + AppState.shared.preview.suppressAutoOpenForCurrentPresentation() + } else { + suppressesHoverSelection = false + AppState.shared.preview.clearPresentationAutoOpenSuppression() + AppState.shared.preview.clearInitialAutoOpenSuppression() + } + + Task { @MainActor in + defer { self.isOpening = false } + + AppState.shared.history.cancelIdleFlush() + await AppState.shared.history.loadForPopupOpenIfNeeded() + AppState.shared.history.startThumbnailBackfillIfNeeded() + + guard AppState.shared.appDelegate?.panel.isPresented != true else { return } + AppState.shared.appDelegate?.panel.open(height: stableHeight, at: popupPosition) + } } func reset() { state = .toggle KeyboardShortcuts.enable(.popup) + suppressesHoverSelection = false + AppState.shared.preview.clearPresentationAutoOpenSuppression() + Task { @MainActor in + // Stop backfill the moment the popup closes; idle flush will free what + // it's already loaded after the short idle window. + await AppState.shared.history.stopThumbnailBackfill() + AppState.shared.history.scheduleIdleFlush() + } } func close() { + isOpening = false AppState.shared.appDelegate?.panel.close() // close() calls reset } @@ -89,6 +129,10 @@ class Popup { AppState.shared.appDelegate?.panel.isPresented != true } + func allowHoverSelection() { + suppressesHoverSelection = false + } + func preferredHeight(for newHeight: CGFloat) -> CGFloat { var height = newHeight diff --git a/Maccy/Observables/SlideoutController.swift b/Maccy/Observables/SlideoutController.swift index 0885645e9..fc8081ea7 100644 --- a/Maccy/Observables/SlideoutController.swift +++ b/Maccy/Observables/SlideoutController.swift @@ -95,6 +95,7 @@ class SlideoutController { } var placement: SlideoutPlacement = .right + var placementLocked = false var state: SlideoutState = .closed var resizingMode: ResizingMode = .none @@ -108,6 +109,10 @@ class SlideoutController { private var autoOpenTask: Task? private var autoOpenSuppressed = false private var autoOpenEnabled = true + private var presentationAutoOpenSuppressed = false + private var initialAutoOpenSuppressionActive = false + private var initialAutoOpenSelectionID: UUID? + private var lastSuppressedAutoOpenSelectionID: UUID? init(onContentResize: @escaping (CGFloat) -> Void, onSlideoutResize: @escaping (CGFloat) -> Void) { self.onContentResize = onContentResize @@ -125,15 +130,30 @@ class SlideoutController { } func computePlacement(window: NSWindow, for size: NSSize) -> SlideoutPlacement { - guard let screen = window.screen?.frame else { return placement } + guard let screen = window.screen?.visibleFrame else { return placement } let windowFrame = window.frame - if windowFrame.minX + size.width > screen.maxX { + let rightSpace = screen.maxX - windowFrame.maxX + let leftSpace = windowFrame.minX - screen.minX + + if rightSpace >= slideoutWidth { + return .right + } else if leftSpace >= slideoutWidth { return .left } else { - return .right + return rightSpace >= leftSpace ? .right : .left } } + func lockPlacement(window: NSWindow) { + let size = computeSizeWithPreview(window.frame.size, state: .open) + placement = computePlacement(window: window, for: size) + placementLocked = true + } + + func unlockPlacement() { + placementLocked = false + } + func computeSizeWithPreview(_ size: NSSize, state newState: SlideoutState) -> NSSize { var newSize = size if newState.isOpen { @@ -165,7 +185,7 @@ class SlideoutController { var newSize = window.frame.size newSize.width = contentWidth newSize = computeSizeWithPreview(newSize, state: self.state) - if state.isOpen { + if state.isOpen && !placementLocked { placement = computePlacement(window: window, for: newSize) } @@ -225,11 +245,15 @@ class SlideoutController { guard autoOpenEnabled else { return } guard !autoOpenSuppressed else { return } + guard !presentationAutoOpenSuppressed else { return } guard !state.isOpen else { return } autoOpenTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(Defaults[.previewDelay])) guard !Task.isCancelled else { return } + guard !presentationAutoOpenSuppressed else { return } + guard AppState.shared.navigator.leadHistoryItem != nil + || AppState.shared.navigator.pasteStackSelected else { return } if !state.isOpen { togglePreview(trigger: .autoOpen) @@ -237,6 +261,36 @@ class SlideoutController { } } + func startAutoOpenForCurrentSelection() { + guard let item = AppState.shared.navigator.leadHistoryItem else { return } + guard shouldAutoOpen(for: item) else { return } + startAutoOpen() + } + + func shouldAutoOpen(for item: HistoryItemDecorator) -> Bool { + guard item.shouldAutoOpenPreview else { return false } + guard !presentationAutoOpenSuppressed else { return false } + + if initialAutoOpenSuppressionActive { + if initialAutoOpenSelectionID == nil { + initialAutoOpenSelectionID = item.id + } else if initialAutoOpenSelectionID != item.id { + initialAutoOpenSuppressionActive = false + } + lastSuppressedAutoOpenSelectionID = item.id + return false + } + + if let lastSuppressedAutoOpenSelectionID { + if lastSuppressedAutoOpenSelectionID == item.id { + return false + } + self.lastSuppressedAutoOpenSelectionID = nil + } + + return true + } + func cancelAutoOpen() { autoOpenTask?.cancel() autoOpenTask = nil @@ -254,4 +308,36 @@ class SlideoutController { func resetAutoOpenSuppression() { autoOpenSuppressed = false } + + func suppressInitialAutoOpen() { + initialAutoOpenSuppressionActive = true + initialAutoOpenSelectionID = nil + lastSuppressedAutoOpenSelectionID = nil + cancelAutoOpen() + } + + func clearInitialAutoOpenSuppression() { + initialAutoOpenSuppressionActive = false + initialAutoOpenSelectionID = nil + lastSuppressedAutoOpenSelectionID = nil + } + + func suppressAutoOpenForCurrentPresentation() { + presentationAutoOpenSuppressed = true + clearInitialAutoOpenSuppression() + closeImmediately() + cancelAutoOpen() + } + + func clearPresentationAutoOpenSuppression() { + presentationAutoOpenSuppressed = false + } + + func closeImmediately() { + state = .closed + contentAnimationWidth = nil + windowAnimationOrigin = nil + windowAnimationOriginBaseState = .closed + resizingMode = .none + } } diff --git a/Maccy/PopupPosition.swift b/Maccy/PopupPosition.swift index 8ba9b44a8..6d1216318 100644 --- a/Maccy/PopupPosition.swift +++ b/Maccy/PopupPosition.swift @@ -38,18 +38,7 @@ enum PopupPosition: String, CaseIterable, Identifiable, CustomStringConvertible, return NSRect.centered(ofSize: size, in: frame).origin } case .statusItem: - if let statusBarButton, let screen = NSScreen.main { - let rectInWindow = statusBarButton.convert(statusBarButton.bounds, to: nil) - if let screenRect = statusBarButton.window?.convertToScreen(rectInWindow) { - var topLeftPoint = NSPoint(x: screenRect.minX, y: screenRect.minY - size.height) - // Ensure that window doesn't spill over to the right screen. - if (topLeftPoint.x + size.width) > screen.frame.maxX { - topLeftPoint.x = screen.frame.maxX - size.width - } - - return topLeftPoint - } - } + return statusItemOrigin(size: size, statusBarButton: statusBarButton) case .lastPosition: if let frame = NSScreen.forPopup?.visibleFrame { let relativePos = Defaults[.windowPosition] @@ -66,4 +55,53 @@ enum PopupPosition: String, CaseIterable, Identifiable, CustomStringConvertible, point.y -= size.height return point } + + private func statusItemOrigin(size: NSSize, statusBarButton: NSStatusBarButton?) -> NSPoint { + let screenRect: NSRect? = if let statusBarButton, + let window = statusBarButton.window { + window.convertToScreen(statusBarButton.convert(statusBarButton.bounds, to: nil)) + } else { + nil + } + + let validStatusRectAndScreen: (NSRect, NSScreen)? = screenRect.flatMap { rect in + NSScreen.screens.first { screen in + let frame = screen.visibleFrame + return rect.width > 0 + && rect.height > 0 + && !(abs(rect.minX) < 1 && abs(rect.minY) < 1) + && screen.frame.intersects(rect) + && rect.midY >= min(screen.frame.maxY, frame.maxY) - 80 + }.map { (rect, $0) } + } + + if let (rect, screen) = validStatusRectAndScreen { + let frame = screen.visibleFrame + var point = NSPoint(x: rect.minX, y: rect.minY - size.height) + point.x = min(max(point.x, frame.minX), frame.maxX - size.width) + point.y = min(max(point.y, frame.minY), frame.maxY - size.height) + return point + } + + let mouseLocation = NSEvent.mouseLocation + let mouseScreen = NSScreen.screens.first { screen in + screen.frame.contains(mouseLocation) + } + + guard let screen = mouseScreen + ?? statusBarButton?.window?.screen + ?? NSScreen.main else { + return .zero + } + + let frame = screen.visibleFrame + let fallbackX = screen.frame.contains(mouseLocation) + ? mouseLocation.x - size.width / 2 + : frame.maxX - size.width + var topLeftPoint = NSPoint(x: fallbackX, y: frame.maxY - size.height) + topLeftPoint.x = min(max(topLeftPoint.x, frame.minX), frame.maxX - size.width) + topLeftPoint.y = min(max(topLeftPoint.y, frame.minY), frame.maxY - size.height) + + return topLeftPoint + } } diff --git a/Maccy/Settings/StorageSettingsPane.swift b/Maccy/Settings/StorageSettingsPane.swift index 143032554..494d90aba 100644 --- a/Maccy/Settings/StorageSettingsPane.swift +++ b/Maccy/Settings/StorageSettingsPane.swift @@ -65,7 +65,7 @@ struct StorageSettingsPane: View { private let sizeFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.minimum = 1 - formatter.maximum = 999 + formatter.maximum = 3000 return formatter }() @@ -97,7 +97,7 @@ struct StorageSettingsPane: View { TextField("", value: $size, formatter: sizeFormatter) .frame(width: 80) .help(Text("SizeTooltip", tableName: "StorageSettings")) - Stepper("", value: $size, in: 1...999) + Stepper("", value: $size, in: 1...3000) .labelsHidden() Text(storageSize) .controlSize(.small) diff --git a/Maccy/Storage.swift b/Maccy/Storage.swift index 775cf45e0..a36d0fb60 100644 --- a/Maccy/Storage.swift +++ b/Maccy/Storage.swift @@ -18,6 +18,20 @@ class Storage { private let url = URL.applicationSupportDirectory.appending(path: "Maccy/Storage.sqlite") init() { + container = Self.makeContainer(url: url) + } + + // SwiftData has no public API to evict registered objects from a context. + // Swapping the entire container is the only way to free hydrated relationship + // blobs without quitting the app. Caller is responsible for ensuring no live + // references to old managed objects exist (otherwise they keep the old + // container alive). + func recreateContainer() { + try? context.save() + container = Self.makeContainer(url: url) + } + + private static func makeContainer(url: URL) -> ModelContainer { var config = ModelConfiguration(url: url) #if DEBUG @@ -27,9 +41,93 @@ class Storage { #endif do { - container = try ModelContainer(for: HistoryItem.self, configurations: config) + return try ModelContainer(for: HistoryItem.self, configurations: config) } catch let error { fatalError("Cannot load database: \(error.localizedDescription).") } } } + +// Generates `thumbnailData` for items that pre-date the migration. Runs on +// its own model context (created by `@ModelActor`) so it can hydrate items +// + their `contents` relationship in the background without bloating the +// main context that the UI uses. +// +// Items the actor processes get one of two outcomes: +// - Image item: `thumbnailData` set to rendered JPEG bytes. +// - Non-image item: `thumbnailData` set to empty `Data()` as a sentinel +// so it gets filtered out of subsequent passes by the predicate, and +// short-circuits the decoder in `HistoryItemDecorator.ensureThumbnailImage`. +@ModelActor +actor ThumbnailBackfiller { + func backfillBatch(limit: Int = 10) async -> Int { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.thumbnailData == nil } + ) + descriptor.fetchLimit = limit + + guard let items = try? modelContext.fetch(descriptor), !items.isEmpty else { + return 0 + } + + var processed = 0 + for item in items { + if let data = item.imageData, + let bytes = HistoryItem.makeThumbnailData(from: data) { + item.thumbnailData = bytes + } else { + item.thumbnailData = Data() + } + processed += 1 + } + + try? modelContext.save() + return processed + } +} + +// Fetches the full sorted history for search on a private SwiftData context. +// The UI resolves the returned ids on the main context in small batches, which +// avoids the long synchronous main-context fetch that stalls scrolling. +@ModelActor +actor SearchHistoryLoader { + func sortedItemIDs(sortBy: Sorter.By, pinTo: PinsPosition) async -> [PersistentIdentifier] { + let pinnedIDs = fetchIDs( + predicate: #Predicate { $0.pin != nil }, + sortBy: sortDescriptors(sortBy) + ) + let unpinnedIDs = fetchIDs( + predicate: #Predicate { $0.pin == nil }, + sortBy: sortDescriptors(sortBy) + ) + + if pinTo == .bottom { + return unpinnedIDs + pinnedIDs + } else { + return pinnedIDs + unpinnedIDs + } + } + + private func fetchIDs( + predicate: Predicate, + sortBy: [SortDescriptor] + ) -> [PersistentIdentifier] { + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: sortBy + ) + let items = (try? modelContext.fetch(descriptor)) ?? [] + return items.map(\.persistentModelID) + } + + private func sortDescriptors(_ sortBy: Sorter.By) -> [SortDescriptor] { + switch sortBy { + case .firstCopiedAt: + return [SortDescriptor(\.firstCopiedAt, order: .reverse)] + case .numberOfCopies: + return [SortDescriptor(\.numberOfCopies, order: .reverse)] + default: + return [SortDescriptor(\.lastCopiedAt, order: .reverse)] + } + } +} diff --git a/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 72fe0d94f..d70033003 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -10,11 +10,15 @@ struct ContentView: View { var body: some View { ZStack { + #if compiler(>=6.2) if #available(macOS 26.0, *) { GlassEffectView() } else { VisualEffectView() } + #else + VisualEffectView() + #endif KeyHandlingView(searchQuery: $appState.history.searchQuery, searchFocused: $searchFocused) { VStack(spacing: 0) { @@ -32,7 +36,6 @@ struct ContentView: View { FooterView(footer: appState.footer) } - .animation(.default.speed(3), value: appState.history.items) .animation( .default.speed(3), value: appState.history.pasteStack?.id @@ -42,6 +45,8 @@ struct ContentView: View { searchFocused = true } .onMouseMove { + appState.popup.allowHoverSelection() + appState.preview.clearPresentationAutoOpenSuppression() appState.navigator.isKeyboardNavigating = false } } slideout: { diff --git a/Maccy/Views/HistoryItemView.swift b/Maccy/Views/HistoryItemView.swift index 656140aee..9904acb7e 100644 --- a/Maccy/Views/HistoryItemView.swift +++ b/Maccy/Views/HistoryItemView.swift @@ -42,12 +42,40 @@ struct HistoryItemView: View { shortcuts: item.shortcuts, isSelected: item.isSelected, selectionIndex: visualIndex, - selectionAppearance: selectionAppearance + selectionAppearance: selectionAppearance, + hoverSelectionEnabled: false ) { - Text(verbatim: item.title) + Text(verbatim: item.displayTitle) + } + .onHover { hovering in + guard hovering else { return } + + if appState.popup.suppressesHoverSelection { + appState.navigator.hoverSelectionWhileKeyboardNavigating = item.id + return + } + + if !appState.navigator.isKeyboardNavigating && !appState.navigator.isMultiSelectInProgress { + appState.navigator.selectWithoutScrolling(item: item) + } else { + appState.navigator.hoverSelectionWhileKeyboardNavigating = item.id + } } .onAppear { + // Fast path: only decodes pre-rendered thumbnailData. No contents fault. item.ensureThumbnailImage() + if item.isPinned { + item.startObservationIfNeeded() + } + // `index` is this row's position in the parent's unpinned array, so + // we can do a constant-time "are we near the end?" check inside. + if item.isUnpinned { + appState.history.itemDidAppear(at: index) + } + } + .onDisappear { + item.cancelSelectionLoad() + item.cleanupPreviewImage() } .onTapGesture { if NSEvent.modifierFlags.contains(.command) && appState.multiSelectionEnabled { diff --git a/Maccy/Views/HistoryListView.swift b/Maccy/Views/HistoryListView.swift index 1bf9f7212..a478a2462 100644 --- a/Maccy/Views/HistoryListView.swift +++ b/Maccy/Views/HistoryListView.swift @@ -116,10 +116,12 @@ struct HistoryListView: View { if scenePhase == .active { searchFocused = true appState.navigator.isKeyboardNavigating = true - appState.navigator.select(item: appState.history.unpinnedItems.first ?? appState.history.pinnedItems.first) appState.preview.enableAutoOpen() appState.preview.resetAutoOpenSuppression() - appState.preview.startAutoOpen() + if !appState.popup.suppressesHoverSelection { + appState.navigator.selectFirstIfNeeded() + appState.preview.startAutoOpenForCurrentSelection() + } } else { modifierFlags.flags = [] appState.navigator.isKeyboardNavigating = true diff --git a/Maccy/Views/HoverSelectionModifier.swift b/Maccy/Views/HoverSelectionModifier.swift index 4f99c5f25..e94c8e82c 100644 --- a/Maccy/Views/HoverSelectionModifier.swift +++ b/Maccy/Views/HoverSelectionModifier.swift @@ -7,6 +7,11 @@ private struct HoverSelectionModifier: ViewModifier { func body(content: Content) -> some View { content.onHover { hovering in if hovering { + if appState.popup.suppressesHoverSelection { + appState.navigator.hoverSelectionWhileKeyboardNavigating = id + return + } + if !appState.navigator.isKeyboardNavigating && !appState.navigator.isMultiSelectInProgress { appState.navigator.selectWithoutScrolling(id: id) } else { diff --git a/Maccy/Views/ListItemView.swift b/Maccy/Views/ListItemView.swift index 0170beb25..f2975d323 100644 --- a/Maccy/Views/ListItemView.swift +++ b/Maccy/Views/ListItemView.swift @@ -40,6 +40,7 @@ struct ListItemView: View { var selectionIndex: Int? var help: LocalizedStringKey? var selectionAppearance: SelectionAppearance = .none + var hoverSelectionEnabled = true @ViewBuilder var title: () -> Title @Default(.showApplicationIcons) private var showIcons @@ -114,7 +115,20 @@ struct ListItemView: View { // The slight opcaity white background is a workaround .background(isSelected ? Color.accentColor.opacity(0.8) : .white.opacity(0.001)) .clipShape(selectionAppearance.rect(cornerRadius: Popup.cornerRadius)) - .hoverSelectionId(selectionId) + .modifier(HoverSelectionEnabledModifier(enabled: hoverSelectionEnabled, id: selectionId)) .help(help ?? "") } } + +private struct HoverSelectionEnabledModifier: ViewModifier { + var enabled: Bool + var id: UUID + + func body(content: Content) -> some View { + if enabled { + content.hoverSelectionId(id) + } else { + content + } + } +} diff --git a/Maccy/Views/PasteStackItemView.swift b/Maccy/Views/PasteStackItemView.swift index 53205bd9e..0127263cf 100644 --- a/Maccy/Views/PasteStackItemView.swift +++ b/Maccy/Views/PasteStackItemView.swift @@ -34,7 +34,7 @@ struct PasteStackItemView: View { selectionIndex: index, selectionAppearance: .none ) { - Text(verbatim: item.title) + Text(verbatim: item.displayTitle) } } } diff --git a/Maccy/Views/PreviewItemView.swift b/Maccy/Views/PreviewItemView.swift index 9a36e7145..5764c9943 100644 --- a/Maccy/Views/PreviewItemView.swift +++ b/Maccy/Views/PreviewItemView.swift @@ -1,6 +1,20 @@ import KeyboardShortcuts import SwiftUI +private struct PreviewTextView: View { + var item: HistoryItemDecorator + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(item.previewText(upTo: HistoryItemDecorator.previewTextPageSize)) + .font(.body) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + struct PreviewItemView: View { var item: HistoryItemDecorator @@ -50,10 +64,7 @@ struct PreviewItemView: View { } } } else { - ScrollView { - Text(item.text) - .font(.body) - } + PreviewTextView(item: item) } Spacer(minLength: 0) @@ -88,6 +99,11 @@ struct PreviewItemView: View { Text("NumberOfCopies", tableName: "PreviewItemView") Text(String(item.item.numberOfCopies)) } + + HStack(spacing: 3) { + Text("DataSize", tableName: "PreviewItemView") + Text(item.dataSize) + } } .controlSize(.small) } diff --git a/Maccy/Views/SlideoutContentView.swift b/Maccy/Views/SlideoutContentView.swift index 7cfabb58b..10b64ca49 100644 --- a/Maccy/Views/SlideoutContentView.swift +++ b/Maccy/Views/SlideoutContentView.swift @@ -7,8 +7,11 @@ struct SlideoutContentView: View { VStack { ToolbarView() - if let item = appState.navigator.leadHistoryItem { + if appState.preview.state != .open { + EmptyView() + } else if let item = appState.navigator.leadHistoryItem { PreviewItemView(item: item) + .id(item.id) } else if let pasteStack = appState.history.pasteStack, appState.navigator.pasteStackSelected { PasteStackPreviewView(pasteStack: pasteStack) diff --git a/Maccy/Views/VisualEffectView.swift b/Maccy/Views/VisualEffectView.swift index 713b7cc65..9864e9fd3 100644 --- a/Maccy/Views/VisualEffectView.swift +++ b/Maccy/Views/VisualEffectView.swift @@ -16,6 +16,7 @@ struct VisualEffectView: NSViewRepresentable { } } +#if compiler(>=6.2) @available(macOS 26.0, *) struct GlassEffectView: NSViewRepresentable { let glassEffectView = NSGlassEffectView() @@ -30,6 +31,7 @@ struct GlassEffectView: NSViewRepresentable { glassEffectView.style = style } } +#endif #Preview { VisualEffectView( diff --git a/Maccy/Views/ar.lproj/PreviewItemView.strings b/Maccy/Views/ar.lproj/PreviewItemView.strings index 063d9a4f1..5820ae269 100644 --- a/Maccy/Views/ar.lproj/PreviewItemView.strings +++ b/Maccy/Views/ar.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "وقت أول نسخة:"; "LastCopyTime" = "وقت آخر نسخة:"; "NumberOfCopies" = "عدد النسخ:"; +"DataSize" = "حجم البيانات:"; "PinKey" = "اضغط على {pinKey} للتثبيت أو إلغاء التثبيت."; "UnpinKey" = "اضغط على {pinKey} للتثبيت أو إلغاء التثبيت."; "DeleteKey" = "اضغط على {deleteKey} للحذف."; diff --git a/Maccy/Views/be.lproj/PreviewItemView.strings b/Maccy/Views/be.lproj/PreviewItemView.strings index c94acec78..11c01f42e 100644 --- a/Maccy/Views/be.lproj/PreviewItemView.strings +++ b/Maccy/Views/be.lproj/PreviewItemView.strings @@ -1,6 +1,7 @@ "Application" = "Дадатак:"; "FirstCopyTime" = "Час першага капіявання:"; "NumberOfCopies" = "Колькасць капіяванняў:"; +"DataSize" = "Памер даных:"; "LastCopyTime" = "Час апошняга капіявання:"; "PinKey" = "Націсніце {pinKey}, каб замацаваць."; "UnpinKey" = "Націсніце {pinKey}, каб адмацаваць."; diff --git a/Maccy/Views/bn.lproj/PreviewItemView.strings b/Maccy/Views/bn.lproj/PreviewItemView.strings index 0cdd404d7..8628dafd1 100644 --- a/Maccy/Views/bn.lproj/PreviewItemView.strings +++ b/Maccy/Views/bn.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = ""; "LastCopyTime" = ""; "NumberOfCopies" = ""; +"DataSize" = "ডেটার আকার:"; "PinKey" = ""; "DeleteKey" = ""; "UnpinKey" = ""; diff --git a/Maccy/Views/bs.lproj/PreviewItemView.strings b/Maccy/Views/bs.lproj/PreviewItemView.strings index a158c6f32..4ec3e2e16 100644 --- a/Maccy/Views/bs.lproj/PreviewItemView.strings +++ b/Maccy/Views/bs.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Prvi put kopirano:"; "LastCopyTime" = "Zadnji put kopirano:"; "NumberOfCopies" = "Broj kopija:"; +"DataSize" = "Veličina podataka:"; "PinKey" = "Pritisni {pinKey} Odkačiš."; "UnpinKey" = "Pritisni {pinKey} Zakačiš."; "DeleteKey" = "Pritisni {deleteKey} za brisanje."; diff --git a/Maccy/Views/ca.lproj/PreviewItemView.strings b/Maccy/Views/ca.lproj/PreviewItemView.strings index 0cdd404d7..d20aa8d1a 100644 --- a/Maccy/Views/ca.lproj/PreviewItemView.strings +++ b/Maccy/Views/ca.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = ""; "LastCopyTime" = ""; "NumberOfCopies" = ""; +"DataSize" = "Mida de les dades:"; "PinKey" = ""; "DeleteKey" = ""; "UnpinKey" = ""; diff --git a/Maccy/Views/ckb.lproj/PreviewItemView.strings b/Maccy/Views/ckb.lproj/PreviewItemView.strings index 2c7de24d7..70fb99529 100644 --- a/Maccy/Views/ckb.lproj/PreviewItemView.strings +++ b/Maccy/Views/ckb.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "کاتی یەکەم کۆپی:"; "LastCopyTime" = "دوایین کاتی کۆپی:"; "NumberOfCopies" = "ژمارەی کۆپییەکان:"; +"DataSize" = "قەبارەی داتا:"; "PinKey" = "دوگمەی {pinKey} دابگرە بۆ چەسپاندن."; "UnpinKey" = "دوگمەی {pinKey} دابگرە بۆ لابردنی چەسپاندن."; "DeleteKey" = "دوگمەی {deleteKey} دابگرە بۆ سڕینەوە."; diff --git a/Maccy/Views/cs.lproj/PreviewItemView.strings b/Maccy/Views/cs.lproj/PreviewItemView.strings index ac30032af..1b7f73ee7 100644 --- a/Maccy/Views/cs.lproj/PreviewItemView.strings +++ b/Maccy/Views/cs.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Čas prvního kopírování:"; "LastCopyTime" = "Čas posledního kopírování:"; "NumberOfCopies" = "Počet kopírování:"; +"DataSize" = "Velikost dat:"; "PinKey" = "Stiskni {pinKey} pro připnutí."; "UnpinKey" = "Stiskni {pinKey} pro odepnutí."; "DeleteKey" = "Stiskni {deleteKey} pro smazání."; diff --git a/Maccy/Views/de.lproj/PreviewItemView.strings b/Maccy/Views/de.lproj/PreviewItemView.strings index b4e9641d6..b12a6791c 100644 --- a/Maccy/Views/de.lproj/PreviewItemView.strings +++ b/Maccy/Views/de.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Zuerst kopiert:"; "LastCopyTime" = "Zuletzt kopiert:"; "NumberOfCopies" = "Anzahl der Kopien:"; +"DataSize" = "Datengröße:"; "PinKey" = "Drücke {pinKey} zum Anheften."; "UnpinKey" = "Drücke {pinKey} zum Lösen."; "DeleteKey" = "Drücke {deleteKey} zum Löschen."; diff --git a/Maccy/Views/el.lproj/PreviewItemView.strings b/Maccy/Views/el.lproj/PreviewItemView.strings index 0cdd404d7..99dd68c22 100644 --- a/Maccy/Views/el.lproj/PreviewItemView.strings +++ b/Maccy/Views/el.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = ""; "LastCopyTime" = ""; "NumberOfCopies" = ""; +"DataSize" = "Μέγεθος δεδομένων:"; "PinKey" = ""; "DeleteKey" = ""; "UnpinKey" = ""; diff --git a/Maccy/Views/en.lproj/PreviewItemView.strings b/Maccy/Views/en.lproj/PreviewItemView.strings index 2c3fb5d2d..0447f08c0 100644 --- a/Maccy/Views/en.lproj/PreviewItemView.strings +++ b/Maccy/Views/en.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "First copy time:"; "LastCopyTime" = "Last copy time:"; "NumberOfCopies" = "Number of copies:"; +"DataSize" = "Data size:"; "PinKey" = "Press {pinKey} to pin."; "UnpinKey" = "Press {pinKey} to unpin."; "DeleteKey" = "Press {deleteKey} to delete."; diff --git a/Maccy/Views/eo.lproj/PreviewItemView.strings b/Maccy/Views/eo.lproj/PreviewItemView.strings index fe13e029a..cc125d057 100644 --- a/Maccy/Views/eo.lproj/PreviewItemView.strings +++ b/Maccy/Views/eo.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Unue kopiita:"; "LastCopyTime" = "Laste kopiita:"; "NumberOfCopies" = "Nombro de kopioj:"; +"DataSize" = "Grando de datumoj:"; "PinKey" = "Premu {pinKey} por fiksi."; "DeleteKey" = "Premu {deleteKey} por forigi."; "UnpinKey" = "Premu {pinKey} por malfiksi."; diff --git a/Maccy/Views/es.lproj/PreviewItemView.strings b/Maccy/Views/es.lproj/PreviewItemView.strings index 25649fbb3..71c172589 100644 --- a/Maccy/Views/es.lproj/PreviewItemView.strings +++ b/Maccy/Views/es.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Hora de la primera copia:"; "LastCopyTime" = "Hora de la última copia:"; "NumberOfCopies" = "Número de copias:"; +"DataSize" = "Tamaño de datos:"; "PinKey" = "Presiona {pinKey} para anclar."; "UnpinKey" = "Presiona {pinKey} para desanclar."; "DeleteKey" = "Presiona {deleteKey} para borrar."; diff --git a/Maccy/Views/fa.lproj/PreviewItemView.strings b/Maccy/Views/fa.lproj/PreviewItemView.strings index 31ac0ec28..eef6d6510 100644 --- a/Maccy/Views/fa.lproj/PreviewItemView.strings +++ b/Maccy/Views/fa.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "زمان اولین کپی:"; "LastCopyTime" = "آخرین زمان کپی:"; "NumberOfCopies" = "تعداد کپی‌ها:"; +"DataSize" = "اندازه داده:"; "PinKey" = "برای سنجاق کردن، کلید {pinKey} را فشار دهید."; "DeleteKey" = "برای حذف، کلید {deleteKey} را فشار دهید."; "UnpinKey" = "برای برداشتن سنجاق، کلید {pinKey} را فشار دهید."; diff --git a/Maccy/Views/fr.lproj/PreviewItemView.strings b/Maccy/Views/fr.lproj/PreviewItemView.strings index 9bbc73de8..18da35722 100644 --- a/Maccy/Views/fr.lproj/PreviewItemView.strings +++ b/Maccy/Views/fr.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Date de la première copie :"; "LastCopyTime" = "Date de la dernière copie :"; "NumberOfCopies" = "Nombre de copies :"; +"DataSize" = "Taille des données :"; "PinKey" = "Appuyez sur {pinKey} pour épingler."; "UnpinKey" = "Appuyez sur {pinKey} pour épingler."; "DeleteKey" = "Appuyez sur {deleteKey} pour supprimer."; diff --git a/Maccy/Views/he.lproj/PreviewItemView.strings b/Maccy/Views/he.lproj/PreviewItemView.strings index d92d54ed6..15d34fccb 100644 --- a/Maccy/Views/he.lproj/PreviewItemView.strings +++ b/Maccy/Views/he.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "מועד ההעתקה הראשונה:"; "LastCopyTime" = "מועד ההעתקה האחרונה:"; "NumberOfCopies" = "מספר העותקים:"; +"DataSize" = "גודל הנתונים:"; "PinKey" = "יש ללחוץ על {pinKey} כדי לנעוץ / לשחרר."; "UnpinKey" = "יש ללחוץ על {pinKey} כדי לנעוץ / לשחרר."; "DeleteKey" = "יש ללחוץ על {deleteKey} כדי למחוק."; diff --git a/Maccy/Views/hi.lproj/PreviewItemView.strings b/Maccy/Views/hi.lproj/PreviewItemView.strings index 0cdd404d7..c261b10b5 100644 --- a/Maccy/Views/hi.lproj/PreviewItemView.strings +++ b/Maccy/Views/hi.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = ""; "LastCopyTime" = ""; "NumberOfCopies" = ""; +"DataSize" = "डेटा आकार:"; "PinKey" = ""; "DeleteKey" = ""; "UnpinKey" = ""; diff --git a/Maccy/Views/hr.lproj/PreviewItemView.strings b/Maccy/Views/hr.lproj/PreviewItemView.strings index 1873db6e2..d9eeba085 100644 --- a/Maccy/Views/hr.lproj/PreviewItemView.strings +++ b/Maccy/Views/hr.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Vrijeme prve kopije:"; "LastCopyTime" = "Vrijeme zadnje kopije:"; "NumberOfCopies" = "Broj kopija:"; +"DataSize" = "Veličina podataka:"; "PinKey" = "Pritisnite {pinKey} za prikvačivanje."; "UnpinKey" = "Pritisnite {pinKey} za otkvačivanje."; "DeleteKey" = "Pritisnite {deleteKey} za brisanje."; diff --git a/Maccy/Views/hu.lproj/PreviewItemView.strings b/Maccy/Views/hu.lproj/PreviewItemView.strings index 5bf60c7fb..5dfdc8a5c 100644 --- a/Maccy/Views/hu.lproj/PreviewItemView.strings +++ b/Maccy/Views/hu.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Első másolás ideje:"; "LastCopyTime" = "Utolsó másolás ideje:"; "NumberOfCopies" = "Másolások száma:"; +"DataSize" = "Adatméret:"; "PinKey" = "Nyomd meg a {pinKey} gombokat a kitűzéshez és a kitűzés levételéhez."; "UnpinKey" = "Nyomd meg a {pinKey} gombokat a kitűzés levételéhez."; "DeleteKey" = "Nyomd meg a {deleteKey} gombokat a törléshez."; diff --git a/Maccy/Views/id.lproj/PreviewItemView.strings b/Maccy/Views/id.lproj/PreviewItemView.strings index 945eac296..a5de21f34 100644 --- a/Maccy/Views/id.lproj/PreviewItemView.strings +++ b/Maccy/Views/id.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Salinan pertama:"; "LastCopyTime" = "Salinan terakhir:"; "NumberOfCopies" = "Jumlah salinan:"; +"DataSize" = "Ukuran data:"; "PinKey" = "Tekan tombol {pinKey} untuk pin."; "DeleteKey" = "Tekan tombol {deleteKey} untuk menghapus."; "UnpinKey" = "Tekan tombol {pinKey} untuk melepas pin."; diff --git a/Maccy/Views/it.lproj/PreviewItemView.strings b/Maccy/Views/it.lproj/PreviewItemView.strings index a3b3af1a6..5dcd66112 100644 --- a/Maccy/Views/it.lproj/PreviewItemView.strings +++ b/Maccy/Views/it.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Tempo della prima copia:"; "LastCopyTime" = "Tempo dell'ultima copia:"; "NumberOfCopies" = "Numero di copie:"; +"DataSize" = "Dimensione dati:"; "PinKey" = "Premi {pinKey} per bloccare."; "UnpinKey" = "Premi {pinKey} per sbloccare."; "DeleteKey" = "Premi {deleteKey} per cancellare."; diff --git a/Maccy/Views/ja.lproj/PreviewItemView.strings b/Maccy/Views/ja.lproj/PreviewItemView.strings index 1c4efc59d..8d0ff2465 100644 --- a/Maccy/Views/ja.lproj/PreviewItemView.strings +++ b/Maccy/Views/ja.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "最初にコピーした日時:"; "LastCopyTime" = "最後にコピーした日時:"; "NumberOfCopies" = "コピー回数:"; +"DataSize" = "データサイズ:"; "PinKey" = "{pinKey}でピン留めします。"; "DeleteKey" = "{deleteKey}で削除します。"; "UnpinKey" = "{pinKey}でピン留めを外します。"; diff --git a/Maccy/Views/ko.lproj/PreviewItemView.strings b/Maccy/Views/ko.lproj/PreviewItemView.strings index a671a92c9..70cacb7a0 100644 --- a/Maccy/Views/ko.lproj/PreviewItemView.strings +++ b/Maccy/Views/ko.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "처음 복사한 시간:"; "LastCopyTime" = "마지막 복사한 시간:"; "NumberOfCopies" = "복사 횟수:"; +"DataSize" = "데이터 크기:"; "PinKey" = "{pinKey}를 눌러 고정(해제)합니다."; "UnpinKey" = "{pinKey}를 눌러 고정(해제)합니다."; "DeleteKey" = "{deleteKey}를 눌러 제거합니다."; diff --git a/Maccy/Views/lt.lproj/PreviewItemView.strings b/Maccy/Views/lt.lproj/PreviewItemView.strings index 583ae3484..d1d349d55 100644 --- a/Maccy/Views/lt.lproj/PreviewItemView.strings +++ b/Maccy/Views/lt.lproj/PreviewItemView.strings @@ -3,6 +3,7 @@ "DeleteKey" = "Paspauskite {deleteKey}, kad ištrintumėte."; "Application" = "Programa:"; "NumberOfCopies" = "Kopijų skaičius:"; +"DataSize" = "Duomenų dydis:"; "PinKey" = "Paspauskite {pinKey}, kad prisegtumėte."; "UnpinKey" = "Paspauskite {pinKey}, kad neprisegtumėte."; "PreviewKey" = "Paspauskite {previewKey} peržiūrai perjungti."; diff --git a/Maccy/Views/lv.lproj/PreviewItemView.strings b/Maccy/Views/lv.lproj/PreviewItemView.strings index da1a7abbd..50ebb1b5f 100644 --- a/Maccy/Views/lv.lproj/PreviewItemView.strings +++ b/Maccy/Views/lv.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Pirmās kopēšanas laiks:"; "LastCopyTime" = "Pēdējās kopēšanas laiks:"; "NumberOfCopies" = "Kopiju skaits:"; +"DataSize" = "Datu lielums:"; "PinKey" = "Nospiediet {pinKey}, lai piespraust."; "UnpinKey" = "Nospiediet {pinKey}, lai atspraust."; "DeleteKey" = "Nospiediet {deleteKey}, lai dzēstu."; diff --git a/Maccy/Views/nb.lproj/PreviewItemView.strings b/Maccy/Views/nb.lproj/PreviewItemView.strings index e5659231c..a6551eb1a 100644 --- a/Maccy/Views/nb.lproj/PreviewItemView.strings +++ b/Maccy/Views/nb.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Tidspunkt første kopiering:"; "LastCopyTime" = "Tidspunkt siste kopiering:"; "NumberOfCopies" = "Antall ganger kopiert:"; +"DataSize" = "Datastørrelse:"; "PinKey" = "Trykk {pinKey} for å merke."; "UnpinKey" = "Trykk {pinKey} for å avmerke."; "DeleteKey" = "Trykk {deleteKey} for å slette."; diff --git a/Maccy/Views/nl.lproj/PreviewItemView.strings b/Maccy/Views/nl.lproj/PreviewItemView.strings index 889336d7f..0dae466a9 100644 --- a/Maccy/Views/nl.lproj/PreviewItemView.strings +++ b/Maccy/Views/nl.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Eerste kopie:"; "LastCopyTime" = "Laatste kopie:"; "NumberOfCopies" = "Aantal kopieën:"; +"DataSize" = "Gegevensgrootte:"; "PinKey" = "Druk op {pinKey} om te pinnen."; "UnpinKey" = "Druk op {pinKey} om te ontpinnen."; "DeleteKey" = "Druk op {deleteKey} om te verwijderen."; diff --git a/Maccy/Views/pl.lproj/PreviewItemView.strings b/Maccy/Views/pl.lproj/PreviewItemView.strings index b54db24d6..4138e13f2 100644 --- a/Maccy/Views/pl.lproj/PreviewItemView.strings +++ b/Maccy/Views/pl.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Skopiowano:"; "LastCopyTime" = "Ostatnio skopiowano:"; "NumberOfCopies" = "Liczba kopii:"; +"DataSize" = "Rozmiar danych:"; "PinKey" = "Naciśnij {pinKey} aby przypiąć wpis."; "UnpinKey" = "Naciśnij {pinKey} aby odpiąć wpis."; "DeleteKey" = "Naciśnij {deleteKey} aby usunąć wpis."; diff --git a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings index e2ad6ca4b..060c24130 100644 --- a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Data da primeira cópia:"; "LastCopyTime" = "Data da última cópia:"; "NumberOfCopies" = "Número de cópias:"; +"DataSize" = "Tamanho dos dados:"; "PinKey" = "Pressione {pinKey} para fixar."; "UnpinKey" = "Pressione {pinKey} para desafixar."; "DeleteKey" = "Pressione {deleteKey} para excluir."; diff --git a/Maccy/Views/pt.lproj/PreviewItemView.strings b/Maccy/Views/pt.lproj/PreviewItemView.strings index 04a89be0d..b5e983622 100644 --- a/Maccy/Views/pt.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt.lproj/PreviewItemView.strings @@ -4,5 +4,6 @@ "PinKey" = "Pressione {pinKey} para fixar."; "DeleteKey" = "Pressione {deleteKey} para apagar."; "NumberOfCopies" = "Quantidade de cópias:"; +"DataSize" = "Tamanho dos dados:"; "UnpinKey" = "Pressione {pinKey} para desafixar."; "PreviewKey" = "Pressione {previewKey} para alternar a pré-visualização."; diff --git a/Maccy/Views/ro.lproj/PreviewItemView.strings b/Maccy/Views/ro.lproj/PreviewItemView.strings index d6ab1d584..867fda783 100644 --- a/Maccy/Views/ro.lproj/PreviewItemView.strings +++ b/Maccy/Views/ro.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Data primei copii:"; "LastCopyTime" = "Data ultimei copii:"; "NumberOfCopies" = "Numărul de copii:"; +"DataSize" = "Dimensiunea datelor:"; "PinKey" = "Apasă {pinKey} pentru a fixa."; "UnpinKey" = "Apasă {pinKey} pentru a detașa."; "DeleteKey" = "Apasă {deleteKey} pentru a șterge."; diff --git a/Maccy/Views/ru.lproj/PreviewItemView.strings b/Maccy/Views/ru.lproj/PreviewItemView.strings index d4ee974cb..d02bc5c75 100644 --- a/Maccy/Views/ru.lproj/PreviewItemView.strings +++ b/Maccy/Views/ru.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Время первого копирования:"; "LastCopyTime" = "Время последнего копирования:"; "NumberOfCopies" = "Количество копирований:"; +"DataSize" = "Размер данных:"; "PinKey" = "Нажмите {pinKey}, чтобы прикрепить."; "UnpinKey" = "Нажмите {pinKey}, чтобы открепить."; "DeleteKey" = "Нажмите {deleteKey}, чтобы удалить."; diff --git a/Maccy/Views/sl.lproj/PreviewItemView.strings b/Maccy/Views/sl.lproj/PreviewItemView.strings index a66cbfc51..70c3953c6 100644 --- a/Maccy/Views/sl.lproj/PreviewItemView.strings +++ b/Maccy/Views/sl.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "Application" = "Aplikacija:"; "FirstCopyTime" = "Čas prvega kopiranja:"; "NumberOfCopies" = "Število kopiranj:"; +"DataSize" = "Velikost podatkov:"; "LastCopyTime" = "Čas zadnjega kopiranja:"; "PinKey" = "Pritisnite {pinKey} za zapenjanje."; "UnpinKey" = "Pritisnite {pinKey} za odzapenjanje."; diff --git a/Maccy/Views/sv.lproj/PreviewItemView.strings b/Maccy/Views/sv.lproj/PreviewItemView.strings index 5fe62fe03..8030c5994 100644 --- a/Maccy/Views/sv.lproj/PreviewItemView.strings +++ b/Maccy/Views/sv.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = ""; "LastCopyTime" = ""; "NumberOfCopies" = ""; +"DataSize" = "Datastorlek:"; "PinKey" = ""; "UnpinKey" = ""; "DeleteKey" = ""; diff --git a/Maccy/Views/ta.lproj/PreviewItemView.strings b/Maccy/Views/ta.lproj/PreviewItemView.strings index 0c62ccac6..6a6a37e25 100644 --- a/Maccy/Views/ta.lproj/PreviewItemView.strings +++ b/Maccy/Views/ta.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "முதல் நகல் நேரம்:"; "LastCopyTime" = "கடைசி நகல் நேரம்:"; "NumberOfCopies" = "பிரதிகளின் எண்ணிக்கை:"; +"DataSize" = "தரவு அளவு:"; "PinKey" = "{pinKey} பெறுநர் முள் அழுத்தவும்."; "DeleteKey" = "நீக்க {deleteKey} ஐ அழுத்தவும்."; "UnpinKey" = "முள்ளை அகற்ற {pinKey} ஐ அழுத்தவும்."; diff --git a/Maccy/Views/th.lproj/PreviewItemView.strings b/Maccy/Views/th.lproj/PreviewItemView.strings index 4191dba65..53832f3be 100644 --- a/Maccy/Views/th.lproj/PreviewItemView.strings +++ b/Maccy/Views/th.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "เวลาคัดลอกครั้งแรก:"; "LastCopyTime" = "เวลาคัดลอกสุดท้าย:"; "NumberOfCopies" = "จำนวนการคัดลอก:"; +"DataSize" = "ขนาดข้อมูล:"; "PinKey" = "กด {pinKey} เพื่อปักหมุด"; "UnpinKey" = "กด {pinKy} เพื่อลบเข็มกลัด"; "DeleteKey" = "กด {deleteKey} เพื่อลบ"; diff --git a/Maccy/Views/tr.lproj/PreviewItemView.strings b/Maccy/Views/tr.lproj/PreviewItemView.strings index da87606e2..b90f58d3e 100644 --- a/Maccy/Views/tr.lproj/PreviewItemView.strings +++ b/Maccy/Views/tr.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "İlk kopyalama zamanı:"; "LastCopyTime" = "Son kopyalama zamanı:"; "NumberOfCopies" = "Kopya sayısı:"; +"DataSize" = "Veri boyutu:"; "PinKey" = "İğnelemek için {pinKey}'a basın."; "UnpinKey" = "Kaldırmak için {pinKey}'a basın."; "DeleteKey" = "Silmek için {deleteKey}'a basın."; diff --git a/Maccy/Views/uk.lproj/PreviewItemView.strings b/Maccy/Views/uk.lproj/PreviewItemView.strings index f54c2fc62..8706ef3bc 100644 --- a/Maccy/Views/uk.lproj/PreviewItemView.strings +++ b/Maccy/Views/uk.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Час першої копії:"; "LastCopyTime" = "Час останньої копії:"; "NumberOfCopies" = "Кількість копій:"; +"DataSize" = "Розмір даних:"; "PinKey" = "Натисніть {pinKey}, щоб кріпити."; "UnpinKey" = "Натисніть {pinKey}, щоб відкріпити."; "DeleteKey" = "Натисніть {deleteKey}, щоб видалити."; diff --git a/Maccy/Views/uz.lproj/PreviewItemView.strings b/Maccy/Views/uz.lproj/PreviewItemView.strings index 2496d8159..62a623fee 100644 --- a/Maccy/Views/uz.lproj/PreviewItemView.strings +++ b/Maccy/Views/uz.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Nusxalangan vaqti:"; "LastCopyTime" = "Oxirgi nusxa olingan vaqti:"; "NumberOfCopies" = "Nusxalar jami soni:"; +"DataSize" = "Ma'lumot hajmi:"; "PinKey" = "Biriktirish uchun {pinKey} tugmasini bosing."; "UnpinKey" = "Biriktirishni o‘chirish uchun {pinKey} tugmasini bosing."; "DeleteKey" = "O‘chirish uchun {deleteKey} tugmasini bosing."; diff --git a/Maccy/Views/vi.lproj/PreviewItemView.strings b/Maccy/Views/vi.lproj/PreviewItemView.strings index 312dcffa8..a721d2518 100644 --- a/Maccy/Views/vi.lproj/PreviewItemView.strings +++ b/Maccy/Views/vi.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "Sao chép lần đầu:"; "LastCopyTime" = "Sao chép lần cuối:"; "NumberOfCopies" = "Số lần sao chép:"; +"DataSize" = "Kích thước dữ liệu:"; "PinKey" = "Nhấn {pinKey} để ghim."; "UnpinKey" = "Nhấn {pinKey} để bỏ ghim."; "DeleteKey" = "Nhấn {deleteKey} để xóa."; diff --git a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings index bd564a58a..aaf7a8b85 100644 --- a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "首次复制时间:"; "LastCopyTime" = "上次复制时间:"; "NumberOfCopies" = "复制次数:"; +"DataSize" = "数据大小:"; "PinKey" = "按 {pinKey} (取消)固定。"; "UnpinKey" = "按 {pinKey} (取消)固定。"; "DeleteKey" = "按 {deleteKey} 删除。"; diff --git a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings index 5b4dc25c8..e5353c963 100644 --- a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings @@ -2,6 +2,7 @@ "FirstCopyTime" = "首次拷貝時間:"; "LastCopyTime" = "上次拷貝時間:"; "NumberOfCopies" = "拷貝次數:"; +"DataSize" = "資料大小:"; "PinKey" = "按 {pinKey} (取消)固定。"; "UnpinKey" = "按 {pinKey} (取消)固定。"; "DeleteKey" = "按 {deleteKey} 刪除。"; diff --git a/MaccyTests/HistoryItemTests.swift b/MaccyTests/HistoryItemTests.swift index 781e07be7..8e16d47c3 100644 --- a/MaccyTests/HistoryItemTests.swift +++ b/MaccyTests/HistoryItemTests.swift @@ -131,6 +131,26 @@ class HistoryItemTests: XCTestCase { XCTAssertEqual(item2.pin, "") } + func testContentFingerprintIgnoresTransientTypesAndOrder() { + let stringContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "foo".data(using: .utf8) + ) + let htmlContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.html.rawValue, + value: "foo".data(using: .utf8) + ) + let transientContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.fromMaccy.rawValue, + value: Data() + ) + + XCTAssertEqual( + HistoryItem.makeContentFingerprint(from: [stringContent, htmlContent]), + HistoryItem.makeContentFingerprint(from: [transientContent, htmlContent, stringContent]) + ) + } + private func historyItem(_ value: String?) -> HistoryItem { let contents = [ HistoryItemContent( diff --git a/README.md b/README.md index 8a412d716..0ee5db986 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,156 @@ +This change raises the storage limit from 999 to 3000 items and improves memory behavior for large clipboard histories. Normal popup browsing no longer hydrates the full history into memory, avoiding excessive memory usage. It also bounds expensive preview/list rendering work and fixes status-bar popup edge cases on multi-display setups. -Logo +## Impact -# [Maccy](https://maccy.app) +- Avoided the previous excessive memory usage seen with large histories, where Activity Monitor could reach roughly 700-900 MB during testing +- Final warm idle after cleanup settled around 93 MB in local testing +- Increased storage capacity from 999 to 3000 items +- Prevented full-history hydration during normal browsing -[![Downloads](https://img.shields.io/github/downloads/p0deje/Maccy/total.svg)](https://github.com/p0deje/Maccy/releases/latest) -[![Build Status](https://img.shields.io/bitrise/716921b669780314/master?token=3pMiCb5dpFzlO-7jTYtO3Q)](https://app.bitrise.io/app/716921b669780314) +## Priority Changes -Maccy is a lightweight clipboard manager for macOS. It keeps the history of what you copy -and lets you quickly navigate, search, and use previous clipboard contents. +### 1. Large History And Memory -Maccy works on macOS Sonoma 14 or higher. +- Added paginated history loading with forward/backward page fetches so normal browsing starts with a bounded set of unpinned items instead of materializing the full store. +- The retained unpinned browse window follows the configured storage limit, with one page as the minimum, instead of using a separate hardcoded window size. +- Changed idle cleanup from 60 seconds to 30 seconds after the popup closes. Cleanup drops decorators, clears cached app icons, resets navigation selection, and recreates the SwiftData container after yielding once to reduce short-lived memory peaks. +- Added thumbnail backfill for older image items. Backfill runs only while the popup is open and stops when the popup closes. +- Added app-icon and preview-image cleanup so cached icons, file watchers, preview images, and image generation tasks do not stay resident unnecessarily. +- Released the Settings window controller when Settings closes so Settings-only SwiftUI panes, observers, Sparkle updater state, and pinned-item query state are not retained for the rest of the app lifetime. - +### 2. Preview And Row Rendering -* [Features](#features) -* [Install](#install) -* [Usage](#usage) -* [Advanced](#advanced) - * [Ignore Copied Items](#ignore-copied-items) - * [Ignore Custom Copy Types](#ignore-custom-copy-types) - * [Speed up Clipboard Check Interval](#speed-up-clipboard-check-interval) -* [FAQ](#faq) - * [Why doesn't it paste when I select an item in history?](#why-doesnt-it-paste-when-i-select-an-item-in-history) - * [When assigning a hotkey to open Maccy, it says that this hotkey is already used in some system setting.](#when-assigning-a-hotkey-to-open-maccy-it-says-that-this-hotkey-is-already-used-in-some-system-setting) - * [How to restore hidden footer?](#how-to-restore-hidden-footer) - * [How to ignore copies from Universal Clipboard?](#how-to-ignore-copies-from-universal-clipboard) - * [My keyboard shortcut stopped working in password fields. How do I fix this?](#my-keyboard-shortcut-stopped-working-in-password-fields-how-do-i-fix-this) -* [Translations](#translations) -* [Motivation](#motivation) -* [License](#license) +- Capped preview text through a bounded preview path instead of rendering very large text bodies directly. +- Added data size display alongside number of copies so the preview can stay small while still showing the original item size. +- List rows now use `displayTitle`, a capped title/excerpt, and search highlighting runs inside that displayed excerpt. +- Keyed preview content by item id and reset preview state cleanly when switching selected items. +- Added preview auto-open safeguards, including suppressing auto-open on initial status-bar presentation until a future hover or selection change. - +### 3. Search -## Features +- Added cancellable, revision-guarded search so stale async work cannot apply results after the query changes. +- Full-history search builds a temporary search corpus instead of replacing the normal paginated browse list. +- Search still shows immediate results from the currently loaded page, then restores the paginated browse window when the query is cleared. -* Lightweight and fast -* Keyboard-first -* Secure and private -* Native UI -* Open source and free +### 4. Status Bar And Popup Positioning -## Install +- Routed status item clicks through `Popup.open` / `Popup.close`, added click debounce, and guarded opening with an `isOpening` flag. +- Added fallback sizing before measured content height is available to avoid strange first-open shapes. +- Validated status item placement and added mouse/active-screen fallbacks when AppKit reports an invalid status rect, then clamps the popup to the chosen screen. +- Locked preview placement during a popup presentation so it does not jump sides while scrolling. -Download the latest version from the [releases](https://github.com/p0deje/Maccy/releases/latest) page, or use [Homebrew](https://brew.sh/): +### 5. Storage And Clipboard -```sh -brew install maccy -``` +- Increased the storage limit from 999 to 3000 items. +image +
-## Usage +- Downscaled very large copied images before storage when they exceed the configured dimension cap. +- Added content fingerprints for duplicate detection, replacing duplicate lookup through all loaded decorators. +- Added pre-rendered thumbnail bytes for image history items. +- Added a fingerprint unit test verifying transient pasteboard types and content order do not affect fingerprint equality. -1. SHIFT (⇧) + COMMAND (⌘) + C to popup Maccy or click on its icon in the menu bar. -2. Type what you want to find. -3. To select the history item you wish to copy, press ENTER, or click the item, or use COMMAND (⌘) + `n` shortcut. -4. To choose the history item and paste, press OPTION (⌥) + ENTER, or OPTION (⌥) + CLICK the item, or use OPTION (⌥) + `n` shortcut. -5. To choose the history item and paste without formatting, press OPTION (⌥) + SHIFT (⇧) + ENTER, or OPTION (⌥) + SHIFT (⇧) + CLICK the item, or use OPTION (⌥) + SHIFT (⇧) + `n` shortcut. -6. To delete the history item, press OPTION (⌥) + DELETE (⌫). -7. To see the full text of the history item, wait a couple of seconds for tooltip. -8. To pin the history item so that it remains on top of the list, press OPTION (⌥) + P. The item will be moved to the top with a random but permanent keyboard shortcut. To unpin it, press OPTION (⌥) + P again. -9. To clear all unpinned items, select _Clear_ in the menu, or press OPTION (⌥) + COMMAND (⌘) + DELETE (⌫). To clear all items including pinned, select _Clear_ in the menu with OPTION (⌥) pressed, or press SHIFT (⇧) + OPTION (⌥) + COMMAND (⌘) + DELETE (⌫). -10. To disable Maccy and ignore new copies, click on the menu icon with OPTION (⌥) pressed. -11. To ignore only the next copy, click on the menu icon with OPTION (⌥) + SHIFT (⇧) pressed. -12. To customize the behavior, check "Preferences…" window, or press COMMAND (⌘) + ,. +## Before Testing -## Advanced +This branch adds optional SwiftData fields for fingerprints and thumbnails. Existing stores should be eligible for lightweight migration. -### Ignore Copied Items +The local `MaccyPatched.app` test build still uses Maccy's existing storage path because storage is hardcoded to `~/Library/Application Support/Maccy/Storage.sqlite`, so back up the local store before testing with important clipboard history. -You can tell Maccy to ignore all copied items: +```bash +STORAGE_DIR="$HOME/Library/Application Support/Maccy" +BACKUP_DIR="$HOME/Desktop/MaccyBackup/$(date +%Y%m%d-%H%M%S)" -```sh -defaults write org.p0deje.Maccy ignoreEvents true # default is false +mkdir -p "$BACKUP_DIR" +for file in \ + "$STORAGE_DIR/Storage.sqlite" \ + "$STORAGE_DIR/Storage.sqlite-shm" \ + "$STORAGE_DIR/Storage.sqlite-wal" +do + [ -e "$file" ] && cp -v "$file" "$BACKUP_DIR/" +done ``` -This is useful if you have some workflow for copying sensitive data. You can set `ignoreEvents` to true, copy the data and set `ignoreEvents` back to false. - -You can also click the menu icon with OPTION (⌥) pressed. To ignore only the next copy, click with OPTION (⌥) + SHIFT (⇧) pressed. - -### Ignore Custom Copy Types - -By default Maccy will ignore certain copy types that are considered to be confidential -or temporary. The default list always include the following types: +## Testing -* `org.nspasteboard.TransientType` -* `org.nspasteboard.ConcealedType` -* `org.nspasteboard.AutoGeneratedType` +- Built Release configuration and installed a local side-by-side test app with: -Also, default configuration includes the following types but they can be removed -or overwritten: +```bash +set -euo pipefail -* `com.agilebits.onepassword` -* `com.typeit4me.clipping` -* `de.petermaurer.TransientPasteboardType` -* `Pasteboard generator type` -* `net.antelle.keeweb` +APP_NAME="MaccyPatched" +BUILD_DIR="/tmp/MaccyPatchedBuild" +APP="$HOME/Applications/$APP_NAME.app" +SOURCE_APP="$BUILD_DIR/Build/Products/Release/Maccy.app" +PLIST="$APP/Contents/Info.plist" -You can add additional custom types using settings. -To find what custom types are used by an application, you can use -free application [Pasteboard-Viewer](https://github.com/sindresorhus/Pasteboard-Viewer). -Simply download the application, open it, copy something from the application you -want to ignore and look for any custom types in the left sidebar. [Here is an example -of using this approach to ignore Adobe InDesign](https://github.com/p0deje/Maccy/issues/125). +set_plist() { + local key="$1" + local type="$2" + local value="$3" -### Speed up Clipboard Check Interval - -By default, Maccy checks clipboard every 500 ms, which should be enough for most users. If you want -to speed it up, you can change it with `defaults`: - -```sh -defaults write org.p0deje.Maccy clipboardCheckInterval 0.1 # 100 ms -``` + /usr/libexec/PlistBuddy -c "Set :$key $value" "$PLIST" 2>/dev/null || + /usr/libexec/PlistBuddy -c "Add :$key $type $value" "$PLIST" +} -## FAQ +pkill -x Maccy || true +pkill -f "$APP" || true -### Why doesn't it paste when I select an item in history? +xcodebuild \ + -project Maccy.xcodeproj \ + -scheme Maccy \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath "$BUILD_DIR" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=YES \ + build -1. Make sure you have "Paste automatically" enabled in Preferences. -2. Make sure "Maccy" is added to System Settings -> Privacy & Security -> Accessibility. +mkdir -p "$HOME/Applications" +rm -rf "$APP" +cp -R "$SOURCE_APP" "$APP" -### When assigning a hotkey to open Maccy, it says that this hotkey is already used in some system setting. +set_plist CFBundleIdentifier string "org.p0deje.$APP_NAME" +set_plist CFBundleName string "$APP_NAME" +set_plist CFBundleDisplayName string "$APP_NAME" +set_plist SUEnableAutomaticChecks bool false -1. Open System settings -> Keyboard -> Keyboard Shortcuts. -2. Find where that hotkey is used. For example, "Convert text to simplified Chinese" is under Services -> Text. -3. Disable that hotkey or remove assigned combination ([screenshot](https://github.com/p0deje/Maccy/assets/576152/446719e6-c3e5-4eb0-95fb-5a811066487f)). -4. Restart Maccy. -5. Assign hotkey in Maccy settings. +codesign --force --deep --sign - "$APP" +xattr -cr "$APP" +codesign --verify --deep --strict "$APP" -### How to restore hidden footer? - -1. Open Maccy window. -2. Press COMMAND (⌘) + , to open preferences. -3. Enable footer in Appearance section. - -If for some reason it doesn't work, run the following command in Terminal.app: - -```sh -defaults write org.p0deje.Maccy showFooter 1 +open -a "$APP" ``` -### How to ignore copies from [Universal Clipboard](https://support.apple.com/en-us/102430)? - -1. Open Preferences -> Ignore -> Pasteboard Types. -2. Add `com.apple.is-remote-clipboard`. - -### My keyboard shortcut stopped working in password fields. How do I fix this? - -If your shortcut produces a character (like `Option+C` → "ç"), macOS security may block it in password fields. Use [Karabiner-Elements](https://karabiner-elements.pqrs.org/) to remap your shortcut to a different combination like `Cmd+Shift+C`. [See detailed solution](docs/keyboard-shortcut-password-fields.md). +- The command above builds `Maccy.app`, installs it locally as `MaccyPatched.app`, patches the local test bundle metadata, signs it ad hoc, clears quarantine attributes, verifies the signature, and opens it. +- It stops any running `Maccy` or `MaccyPatched` process first because the local side-by-side build still uses Maccy's shared storage path. +- Tested with a full dataset (3000 items) to validate performance and memory behavior under maximum load. +- Tested large text previews ranging from 95 KB to 150 MB. +- Tested search performance with hundreds of results. +- Verified idle close/reopen and Settings open/close behavior. +- Verified status-bar first-open behavior and popup placement across MacBook display and external monitor. +- Verified stable scroll behavior after reverting the aggressive moving window to a fixed storage-limit-sized window. -## Translations +## Screenshots -The translations are hosted in [Weblate](https://hosted.weblate.org/engage/maccy/). -You can use it to suggest changes in translations and localize the application to a new language. +### 1. Initial idle after launch: around 47.9 MB +Initial idle around 47.9 MB -[![Translation status](https://hosted.weblate.org/widget/maccy/multi-auto.svg)](https://hosted.weblate.org/engage/maccy/) +### 2. Temporary memory peak during activity/scroll: around 150.4 MB +Temporary memory peak around 150.4 MB -## Motivation +### 3. Warm idle after normal use: around 101.7 MB +Warm idle around 101.7 MB -There are dozens of similar applications out there, so why build another? -Over the past years since I moved from Linux to macOS, I struggled to find -a clipboard manager that is as free and simple as [Parcellite](http://parcellite.sourceforge.net), -but I couldn't. So I've decided to build one. +### 4. Final warm idle after idle cleanup: around 93.3 MB +Final warm idle around 93.3 MB -Also, I wanted to learn Swift and get acquainted with macOS application development. +## Notes +- The in-memory unpinned window intentionally follows the configured storage limit. A smaller moving window reduced memory further in theory, but caused SwiftUI scroll instability and occasional blank-list behavior during testing, so this patch keeps the stable approach. +- Full-history search no longer mutates the paginated browse list. A future cleanup could move more of the search work off the main actor. +- Image resizing and thumbnail generation currently rely on AppKit APIs. A future cleanup could move this to ImageIO/CoreGraphics for safer background rendering. -## License +## Important Note -[MIT](./LICENSE) +This repository is a fork of the original [Maccy](https://maccy.app) project. It is not affiliated with, endorsed by, or maintained by the original Maccy project, and the original Maccy maintainers are not responsible for changes made in this fork. \ No newline at end of file