From a55d773df9afc5b8b86401d4a60dbe53f1e5b068 Mon Sep 17 00:00:00 2001 From: Nikolai Berezovskii Date: Thu, 3 Sep 2026 19:32:03 +0300 Subject: [PATCH] Remove favorites and paste stack --- CHANGELOG.md | 4 + Copy/App/AppCoordinator.swift | 152 +-------- Copy/App/AppDelegate.swift | 25 -- Copy/App/DemoData.swift | 21 +- Copy/PasteStack/PasteStackController.swift | 275 ---------------- Copy/PasteStack/PasteStackEngine.swift | 149 --------- Copy/PasteStack/PasteStackModel.swift | 111 ------- Copy/PasteStack/PasteStackView.swift | 308 ------------------ Copy/Settings/GeneralSettings.swift | 2 +- Copy/Settings/HistorySettings.swift | 4 +- Copy/Settings/SettingsStore.swift | 17 +- Copy/Settings/SettingsView.swift | 2 +- Copy/Settings/ShortcutsSettings.swift | 1 - Copy/Settings/StorageUsageSection.swift | 11 +- Copy/Shelf/ItemCardView.swift | 27 +- Copy/Shelf/PreviewPane.swift | 2 +- Copy/Shelf/SearchTokenField.swift | 3 +- Copy/Shelf/ShelfPanelController.swift | 2 +- Copy/Shelf/ShelfRootView.swift | 58 ---- Copy/Shelf/ShelfViewModel.swift | 56 +--- Copy/Shelf/TipsSheet.swift | 8 - Copy/Support/CodeHighlight.swift | 2 +- Copy/Support/DesignTokens.swift | 3 - Copy/Support/GlassSurface.swift | 4 +- Copy/Support/IconButton.swift | 2 +- Copy/Support/WindowDragHandle.swift | 16 - .../Sources/CopyCore/Models/Constants.swift | 6 - .../Sources/CopyCore/Models/Records.swift | 2 + .../Sources/CopyCore/Paste/PasteService.swift | 5 - .../CopyCore/Paste/PasteStackQueue.swift | 49 --- .../Sources/CopyCore/Storage/ArchiveIO.swift | 5 +- .../Sources/CopyCore/Storage/ItemStore.swift | 117 ++----- .../CopyCore/Storage/SearchFilter.swift | 4 - .../CopyCore/Storage/SmartSearch.swift | 23 +- .../Tests/CopyCoreTests/ArchiveIOTests.swift | 8 +- .../Tests/CopyCoreTests/CarryoverTests.swift | 10 +- .../CopyCoreTests/FavoritesPagingTests.swift | 129 -------- .../CopyCoreTests/ItemStoreSearchTests.swift | 13 +- .../Tests/CopyCoreTests/ItemStoreTests.swift | 42 --- .../Tests/CopyCoreTests/PageWindowTests.swift | 14 +- .../CopyCoreTests/PasteStackQueueTests.swift | 137 -------- .../CopyCoreTests/PinboardStoreTests.swift | 2 +- .../Tests/CopyCoreTests/RetentionTests.swift | 13 +- .../CopyCoreTests/SmartSearchTests.swift | 2 - .../CopyCoreTests/UndoSnapshotTests.swift | 4 +- README.md | 3 - 46 files changed, 96 insertions(+), 1757 deletions(-) delete mode 100644 Copy/PasteStack/PasteStackController.swift delete mode 100644 Copy/PasteStack/PasteStackEngine.swift delete mode 100644 Copy/PasteStack/PasteStackModel.swift delete mode 100644 Copy/PasteStack/PasteStackView.swift delete mode 100644 Copy/Support/WindowDragHandle.swift delete mode 100644 CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift delete mode 100644 CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift delete mode 100644 CopyCore/Tests/CopyCoreTests/PasteStackQueueTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d344da..e49d1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- Favorites and Paste Stack, simplifying the shelf around history and pinboards. + ## [0.2.0] - 2026-09-03 The first independently maintained Copy release. diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 9fc8421..124852e 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -26,20 +26,6 @@ final class AppCoordinator { private(set) lazy var ocrController = OCRController(store: store) private(set) lazy var archiveController = ArchiveController(store: store, pinboardStore: pinboardStore) - /// Assigned eagerly in `init()` (from a local, not `self.pasteStackModel`) so the - /// monitor's `onCapture` closure can capture it directly for the auto-enqueue-while- - /// active behavior — `self` isn't fully initialized yet at that point in `init()`, - /// but a plain local reference to this same instance is fine to capture. Its - /// `onActiveChange` handler is wired afterward, once `self` is safe to capture. - private let pasteStackModel: PasteStackModel - private lazy var pasteStackController = PasteStackController( - model: pasteStackModel, - hideDuringScreenSharing: settings.hideDuringScreenSharing, - proDark: settings.shelfProDark) - private lazy var pasteStackEngine = PasteStackEngine(onIntercept: { [weak self] in - self?.pasteNextViaEngine() - }) - /// How often the retention pruner re-runs while the app stays open. private static let retentionInterval: TimeInterval = 12 * 60 * 60 @@ -249,9 +235,6 @@ final class AppCoordinator { paste() } } - shelfViewModel.onAddToPasteStack = { [weak self] item in - self?.addToPasteStack(item) - } shelfViewModel.onOpenURL = { [weak controller] url in if let controller { controller.hide(restoreFocus: true) { @@ -278,9 +261,6 @@ final class AppCoordinator { shelfViewModel.onNewItem = { [weak self] in self?.newItem() } - shelfViewModel.onTogglePasteStack = { [weak self] in - self?.togglePasteStack() - } shelfViewModel.onTogglePrivacyMode = { [weak self] in self?.togglePause() } @@ -357,10 +337,8 @@ final class AppCoordinator { self.pinboardStore = pinboardStore self.pasteService = PasteService(pasteboard: NSPasteboard.general, keyPoster: CGKeyEventPoster()) - let pasteStackModel = PasteStackModel(store: store) - self.pasteStackModel = pasteStackModel if isDemo { - DemoData.seed(store: store, pinboards: pinboardStore, pasteStack: pasteStackModel) + DemoData.seed(store: store, pinboards: pinboardStore) } let settings = SettingsStore() self.settings = settings @@ -378,26 +356,18 @@ final class AppCoordinator { let app = NSWorkspace.shared.frontmostApplication return (app?.bundleIdentifier, app?.localizedName) }, - onCapture: { [persistQueue, linkFetcher, ocrController, settings, pasteStackModel] captured in + onCapture: { [persistQueue, linkFetcher, ocrController, settings] captured in persistQueue.async { do { let saved = try store.save(captured) DispatchQueue.main.async { linkFetcher.fetchIfNeeded(for: saved, enabled: settings.fetchLinkPreviews) ocrController.recognizeIfNeeded(for: saved, enabled: settings.recognizeImageText) - // While the stack is active, new copies join the queue too — - // "copying while the stack is active" enqueues automatically. - if pasteStackModel.isActive { - pasteStackModel.enqueue(saved) - } // Already on the main queue; hop into main-actor isolation for - // the one-time activation nudges (see the methods). + // the one-time first-copy coach. MainActor.assumeIsolated { CopySoundPlayer.shared.play(settings.copySound) AppCoordinator.showFirstCopyCoachIfNeeded() - if !pasteStackModel.isActive { - AppCoordinator.notePasteStackOpportunity() - } } } } catch { @@ -413,38 +383,16 @@ final class AppCoordinator { } settings.onHideDuringScreenSharingChange = { [weak self] hide in self?.shelfController.setHideDuringScreenSharing(hide) - self?.pasteStackController.setHideDuringScreenSharing(hide) } settings.onCompactShelfChange = { [weak self] compact in self?.shelfController.setCompactShelf(compact) } settings.onShelfProDarkChange = { [weak self] proDark in self?.shelfController.setProDark(proDark) - self?.pasteStackController.setProDark(proDark) } settings.onShowOnboarding = { [weak self] in self?.showOnboarding() } - // `self` is fully initialized past this point, so it's safe to capture weakly - // in `onActiveChange` — the single fan-out point for palette visibility AND - // engine activation, so the two can never drift out of lockstep (see the - // `pasteStackModel` doc comment above). - pasteStackModel.onActiveChange = { [weak self] isActive in - guard let self else { return } - self.pasteStackController.syncVisibility(to: isActive) - self.shelfViewModel.isPasteStackOn = isActive - if isActive { - let tapCreated = self.pasteStackEngine.activate() - NSLog("Copy: Paste Stack tap \(tapCreated ? "activated" : "could not be created, falling back to hotkey")") - if !tapCreated { - KeyboardShortcuts.enable(.pasteNextFromStack) - HUD.show("Use Control Option Command N to paste the next item") - } - } else { - self.pasteStackEngine.deactivate() - KeyboardShortcuts.disable(.pasteNextFromStack) - } - } } func start() { @@ -481,26 +429,8 @@ final class AppCoordinator { HUD.show("Saved to Copy. Press \(hotkey) to open it.", duration: 2.8) } - /// Tracks recent capture times; when an established user copies several things in - /// quick succession (exactly when a paste stack would help) and hasn't met the - /// feature yet, introduce it, once. Gated behind the first-copy coach so a brand-new - /// user copying a fast burst isn't double-nagged. - private static var recentCaptureTimes: [Date] = [] - private static func notePasteStackOpportunity() { - let defaults = UserDefaults.standard - guard defaults.bool(forKey: "hasSeenFirstCopyCoach"), - !defaults.bool(forKey: "hasSeenPasteStackHint") else { return } - let now = Date() - recentCaptureTimes.append(now) - recentCaptureTimes = recentCaptureTimes.filter { now.timeIntervalSince($0) < 10 } - guard recentCaptureTimes.count >= 3 else { return } - defaults.set(true, forKey: "hasSeenPasteStackHint") - let hotkey = KeyboardShortcuts.getShortcut(for: .togglePasteStack)?.description ?? "⇧⌘C" - HUD.show("Collecting a few things? Press \(hotkey) for the paste stack.", duration: 3.0) - } - /// Prunes items past the configured retention window on the persist queue, - /// always sparing favorites and pinboard members (`ItemStore.prune` invariant). + /// always sparing pinboard members (`ItemStore.prune` invariant). private func runRetentionPrune() { let cutoff = settings.retention.cutoff let store = self.store @@ -606,15 +536,6 @@ final class AppCoordinator { } } - /// Enqueues `item` into the Paste Stack (activating the palette if it wasn't - /// already) from the card context menu's "Add to Paste Stack" action. The palette - /// re-fits its own size to the new row via `PasteStackView.onContentChange`, so - /// there's no need to poke the controller directly here. - func addToPasteStack(_ item: ClipItem) { - pasteStackModel.enqueue(item) - HUD.show("Added to Paste Stack") - } - /// Places the primary shelf card on the system clipboard without synthesizing a /// paste. The self marker keeps the monitor from ingesting a duplicate; touching /// the existing item makes it the current entry in history too. Returns whether the @@ -669,70 +590,9 @@ final class AppCoordinator { HUD.show("Color copied") } - /// Whether the Paste Stack palette/engine are currently active — read by - /// `AppDelegate` to reflect a checkmark on the "Paste Stack" menu item. - var isPasteStackActive: Bool { pasteStackModel.isActive } - - /// Flips the Paste Stack on or off. All activation/deactivation — the palette, the - /// CGEvent tap, and the `.pasteNextFromStack` fallback hotkey — fans out from - /// `pasteStackModel.isActive`'s `didSet`, wired to `onActiveChange` in `init()`. - func togglePasteStack() { - pasteStackModel.isActive.toggle() - } - - /// Advances the queue and places the next item's representations on the - /// pasteboard, shared by both the engine-intercepted path and the - /// `.pasteNextFromStack` fallback hotkey. Returns `false` once the queue is - /// exhausted, having already deactivated the stack and shown the "finished" HUD. - @discardableResult - private func placeNextStackItem() -> Bool { - guard let reps = pasteStackModel.advanceAndResolve() else { - deactivatePasteStack() - HUD.show("Paste Stack finished") - return false - } - pasteService.place(reps, plainTextOnly: false) - return true - } - - /// Called by `PasteStackEngine`'s `onIntercept` when the CGEvent tap swallows a - /// plain ⌘V. This path only runs with Accessibility granted (the tap itself - /// requires it), so after placing the item it's safe to re-synthesize a marked ⌘V - /// and have the frontmost app receive it automatically. - private func pasteNextViaEngine() { - guard placeNextStackItem() else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - PasteStackEngine.postMarkedPasteKeystroke() - } - } - - /// `.pasteNextFromStack` fallback hotkey handler — only reachable while the tap - /// couldn't be created (no Accessibility; see `pasteStackModel.onActiveChange`). - /// Without Accessibility we can't reliably synthesize a ⌘V ourselves (same reason - /// `pasteFromShelf`/`onPasteMultiple` gate `sendPasteKeystroke()` behind - /// `AXIsProcessTrusted()`), so this places the item and lets the user's own ⌘V — - /// which the OS delivers directly, no synthesis needed — do the actual pasting. - func pasteNextFromStack() { - guard placeNextStackItem() else { return } - HUD.show("Ready to paste. Press Command V.") - } - - private func deactivatePasteStack() { - pasteStackModel.isActive = false - } - - /// Called from `AppDelegate.applicationWillTerminate` so the event tap never - /// outlives the app process. `pasteStackEngine.deactivate()` is called directly - /// too, as a defensive no-op, in case the tap was ever active without - /// `pasteStackModel.isActive` reflecting it. - func applicationWillTerminate() { - deactivatePasteStack() - pasteStackEngine.deactivate() - } - func clearHistory() { do { - try store.clearHistory(keepFavorites: true) + try store.clearHistory() } catch { NSLog("Copy: failed to clear history: \(error)") } @@ -747,7 +607,7 @@ final class AppCoordinator { func confirmAndClearHistory() { let alert = NSAlert() alert.messageText = "Clear clipboard history?" - alert.informativeText = "Favorites are kept. This cannot be undone." + alert.informativeText = "Pinboard items are kept. This cannot be undone." alert.addButton(withTitle: "Clear") alert.addButton(withTitle: "Cancel") alert.window.level = .statusBar diff --git a/Copy/App/AppDelegate.swift b/Copy/App/AppDelegate.swift index 4431645..59b1023 100644 --- a/Copy/App/AppDelegate.swift +++ b/Copy/App/AppDelegate.swift @@ -6,8 +6,6 @@ import Sparkle extension KeyboardShortcuts.Name { static let toggleShelf = Self("toggleShelf", initial: .init(.v, modifiers: [.command, .shift])) - static let togglePasteStack = Self("togglePasteStack", initial: .init(.c, modifiers: [.command, .shift])) - static let pasteNextFromStack = Self("pasteNextFromStack", initial: .init(.n, modifiers: [.control, .option, .command])) /// No default shortcut — the user opts in from Settings. Pastes the most recent /// history item directly into the frontmost app without opening the shelf. static let quickPasteLatest = Self("quickPasteLatest") @@ -63,16 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { KeyboardShortcuts.onKeyDown(for: .toggleShelf) { [weak self] in self?.coordinator.toggleShelf() } - KeyboardShortcuts.onKeyDown(for: .togglePasteStack) { [weak self] in - self?.coordinator.togglePasteStack() - } - // Fallback path only: `AppCoordinator` enables this while the Paste Stack is - // active and the CGEvent tap couldn't be created (no Accessibility), and - // disables it again on deactivation — see `pasteStackModel.onActiveChange`. - KeyboardShortcuts.onKeyDown(for: .pasteNextFromStack) { [weak self] in - self?.coordinator.pasteNextFromStack() - } - KeyboardShortcuts.disable(.pasteNextFromStack) KeyboardShortcuts.onKeyDown(for: .quickPasteLatest) { [weak self] in self?.coordinator.quickPasteLatest() } @@ -155,10 +143,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return image } - func applicationWillTerminate(_ notification: Notification) { - coordinator.applicationWillTerminate() - } - func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() @@ -192,11 +176,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // extras are "Open Copy" and the live recent-items list above, which are // menu-bar-only by nature (the shelf is already open, and already shows items). menu.addItem(.separator()) - let pasteStack = NSMenuItem(title: "Paste Stack", action: #selector(togglePasteStack), keyEquivalent: "c") - pasteStack.keyEquivalentModifierMask = [.command, .shift] - pasteStack.target = self - pasteStack.state = coordinator.isPasteStackActive ? .on : .off - menu.addItem(pasteStack) let pause = NSMenuItem(title: coordinator.isPaused ? "Resume Monitoring" : "Pause Monitoring", action: #selector(togglePause), keyEquivalent: "") pause.target = self @@ -297,10 +276,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { updaterController.checkForUpdates(sender) } - @objc private func togglePasteStack() { - coordinator.togglePasteStack() - } - @objc private func clearHistory() { coordinator.confirmAndClearHistory() } diff --git a/Copy/App/DemoData.swift b/Copy/App/DemoData.swift index 68a7d9a..050de9c 100644 --- a/Copy/App/DemoData.swift +++ b/Copy/App/DemoData.swift @@ -19,16 +19,16 @@ enum DemoData { private static let mail: App = ("com.apple.mail", "Mail") @MainActor - static func seed(store: ItemStore, pinboards: PinboardStore, pasteStack: PasteStackModel) { + static func seed(store: ItemStore, pinboards: PinboardStore) { do { - try seedThrowing(store: store, pinboards: pinboards, pasteStack: pasteStack) + try seedThrowing(store: store, pinboards: pinboards) } catch { NSLog("Copy: demo seed failed: \(error)") } } @MainActor - private static func seedThrowing(store: ItemStore, pinboards: PinboardStore, pasteStack: PasteStackModel) throws { + private static func seedThrowing(store: ItemStore, pinboards: PinboardStore) throws { let now = Date() let minute = 60.0, hour = 3600.0, day = 86400.0 func ago(_ seconds: TimeInterval) -> Date { now.addingTimeInterval(-seconds) } @@ -127,7 +127,7 @@ enum DemoData { title: "Copy — Marketing site (Figma)", from: figma, at: ago(34 * minute), favicon: .systemPurple) - try text("Standup at 10:30 — demo the smart search and paste stack. Can someone record the video? 🎥", + try text("Standup at 10:30 — demo the smart search and image previews. Can someone record the video? 🎥", from: slack, at: ago(45 * minute)) let jsonCode = """ @@ -170,12 +170,6 @@ enum DemoData { bottom: NSColor(calibratedRed: 0.10, green: 0.55, blue: 0.45, alpha: 1), label: "Old concept") - // MARK: favorites (float above the divider) - - for id in [codeID, githubID, invoiceID, brandColorID] { - try store.setFavorite(itemID: id, true) - } - // MARK: pinboards let work = try pinboards.create(name: "Work", symbol: "briefcase", tint: "007AFF") @@ -192,13 +186,6 @@ enum DemoData { try pinboards.add(itemID: id, to: snippets.id!) } - // MARK: paste stack (pre-filled; the palette isn't auto-shown) - - let saved = try store.recentItems(limit: 200) - func uuid(of id: Int64) -> String? { saved.first { $0.id == id }?.uuid } - for uuid in [shellID, githubID, brandColorID].compactMap(uuid(of:)) { - pasteStack.queue.enqueue(uuid) - } } // MARK: image generation (no bundled assets) diff --git a/Copy/PasteStack/PasteStackController.swift b/Copy/PasteStack/PasteStackController.swift deleted file mode 100644 index c4f8ed7..0000000 --- a/Copy/PasteStack/PasteStackController.swift +++ /dev/null @@ -1,275 +0,0 @@ -import AppKit -import CopyCore -import SwiftUI - -/// Floating Paste Stack palette. Unlike `ShelfPanelController`, this panel must never -/// activate the app or become key — the whole point of the stack is that plain ⌘V -/// keeps going to the frontmost app while the palette floats on top (Task 7's CGEvent -/// tap intercepts that keystroke). So `show()` only calls `orderFrontRegardless()`, -/// never `makeKeyAndOrderFront`/`NSApp.activate`. `.nonactivatingPanel` panels still -/// deliver mouse events (button clicks, list drag-reorder) without becoming key or -/// stealing focus, which is what makes the palette interactive despite that. -@MainActor -final class PasteStackController { - static let width: CGFloat = 280 - static let maxHeight: CGFloat = 420 - static let inset: CGFloat = 16 - - private let model: PasteStackModel - private var panel: KeyablePanel? - // Separate key-capable child window that hosts the rich `EditItemSheet` when a row's - // pencil is tapped. The palette panel itself must never become key (⌘V would then paste - // into Copy), so editing happens in this window, which can take keyboard focus — the - // same split the shelf uses (`ShelfPanelController.presentModal`). - private var modalPanel: KeyablePanel? - private var hideDuringScreenSharing: Bool - private var proDark: Bool - - init(model: PasteStackModel, hideDuringScreenSharing: Bool, proDark: Bool) { - self.model = model - self.hideDuringScreenSharing = hideDuringScreenSharing - self.proDark = proDark - } - - /// Pushed live by `AppCoordinator` via `SettingsStore.onShelfProDarkChange`. Forces - /// the palette (and its hosted SwiftUI content) to a dark appearance so it matches - /// the pro-dark shelf; `nil` returns to following the system, mirroring - /// `ShelfPanelController.setProDark`. The accent tint is baked into the hosted view at - /// `makePanel` time, so when the (cached) palette isn't on screen we drop it and let - /// the next `show()` rebuild it with both the appearance and the tint from the new - /// value; when it is on screen we can only update the window appearance live (the tint - /// refreshes on its next open). - func setProDark(_ on: Bool) { - proDark = on - if let panel, panel.isVisible { - panel.appearance = on ? NSAppearance(named: .darkAqua) : nil - } else { - panel?.orderOut(nil) - panel = nil - } - } - - /// Applied at panel creation and pushed live here when the setting changes - /// (`AppCoordinator` wires `SettingsStore.onHideDuringScreenSharingChange`). `.none` - /// excludes the palette from screen recordings/captures/shares; `.readOnly` is - /// AppKit's normal default (content visible, not modifiable by other processes). - func setHideDuringScreenSharing(_ hide: Bool) { - hideDuringScreenSharing = hide - panel?.sharingType = hide ? .none : .readOnly - panel?.childWindowSharingType = hide ? .none : .readOnly - } - - var isVisible: Bool { panel?.isVisible ?? false } - - /// Single entry point for palette visibility, wired to `model.onActiveChange` by - /// `AppCoordinator`. Also fine to call directly (e.g. from `show()`'s own - /// content-change hook) since both `show()` and `hide()` are idempotent. - func syncVisibility(to isActive: Bool) { - if isActive { - show() - } else { - hide() - } - } - - /// Shows the palette at the top-right of the mouse's screen, sized to fit the - /// current queue (capped at `maxHeight`). Only positions fresh when the panel isn't - /// already visible — once the user has moved/is looking at the palette, content - /// changes must not teleport it back to the corner; that's what `resizeToFit()` - /// (wired to `PasteStackView.onContentChange`) is for. - func show() { - // Drop any queued uuid that's gone missing (deleted/pruned) since being - // queued. `PasteStackModel.items()` is a pure read used from SwiftUI bodies, - // so this explicit event point — the palette becoming visible — is where that - // bookkeeping actually happens. - model.reconcile() - - let panel = self.panel ?? makePanel() - self.panel = panel - - guard !panel.isVisible else { - resizeToFit() - return - } - - guard let screen = NSScreen.screens.first(where: { - NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) - }) ?? NSScreen.main else { return } - - let height = computeHeight() - let frame = NSRect( - x: screen.visibleFrame.maxX - Self.width - Self.inset, - y: screen.visibleFrame.maxY - height - Self.inset, - width: Self.width, - height: height - ) - panel.setFrame(frame, display: true) - panel.orderFrontRegardless() - } - - func hide() { - panel?.orderOut(nil) - } - - /// Re-fits the palette's height to the current queue while keeping its current - /// on-screen position, anchored at the current top edge. Called from - /// `PasteStackView.onContentChange` (items added/removed/reordered) so the palette - /// doesn't jump back to the top-right corner on every copy while the stack is - /// active — only `show()`'s fresh-activation path positions there. AppKit frames - /// are bottom-left-anchored, so holding the top edge fixed while the height changes - /// means recomputing the origin's y: `newOriginY = currentTopY - newHeight`. - private func resizeToFit() { - guard let panel, panel.isVisible else { return } - let current = panel.frame - let currentTopY = current.origin.y + current.height - let newHeight = computeHeight() - let newFrame = NSRect( - x: current.origin.x, - y: currentTopY - newHeight, - width: current.width, - height: newHeight - ) - panel.setFrame(newFrame, display: true) - } - - private func computeHeight() -> CGFloat { - let count = model.items().count - let header: CGFloat = 34 - // Empty palette has no footer (order picker + Clear are hidden), so it's just the - // header, a divider, and the compact empty state. - if count == 0 { - return header + 1 + 120 - } - let dividers: CGFloat = 2 - let footer: CGFloat = 82 - let content: CGFloat = CGFloat(count) * PasteStackView.rowHeight + 8 - return min(max(header + dividers + footer + content, 180), Self.maxHeight) - } - - private func makePanel() -> KeyablePanel { - let panel = KeyablePanel(contentRect: .zero, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, defer: false) - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.isOpaque = false - panel.backgroundColor = .clear - panel.appearance = proDark ? NSAppearance(named: .darkAqua) : nil - panel.hidesOnDeactivate = false - panel.becomesKeyOnlyIfNeeded = true - panel.isFloatingPanel = true - // Off: the window is dragged explicitly from the header (WindowDragArea → - // performDrag), so dragging a list row is free to reorder it. See PasteStackView. - panel.isMovableByWindowBackground = false - panel.sharingType = hideDuringScreenSharing ? .none : .readOnly - panel.childWindowSharingType = hideDuringScreenSharing ? .none : .readOnly - - // The panel's contentView is a real NSVisualEffectView, not the SwiftUI - // `.glassEffect`: on macOS 26 that effect has no backing NSView, so the empty areas - // of this non-key panel let clicks fall through to the app behind. A visual-effect - // view is a real view that absorbs every click over its frame while still reading as - // dark glass, consistent with the shelf. The SwiftUI content sits on top of it. - let hosting = NSHostingView(rootView: PasteStackView( - model: model, - onClose: { [weak model] in model?.isActive = false }, - onEdit: { [weak self] item in self?.presentEditor(for: item) }, - // Defer to the next runloop tick: the + button mutates the queue from inside a - // SwiftUI update, and resizing the panel (setFrame) synchronously there would - // re-enter SwiftUI's layout pass mid-update and corrupt the window. Adds from - // outside the view (the paste-stack hotkey) don't hit that, but deferring is - // safe for them too. - onContentChange: { [weak self] in - DispatchQueue.main.async { self?.resizeToFit() } - } - ) - .tint(proDark ? Tokens.electricBlue : nil)) - hosting.translatesAutoresizingMaskIntoConstraints = false - - // The material view (a real NSView) makes every pixel non-transparent, so the - // window server stops passing clicks through the panel to the app behind — the - // actual root cause of the click-through under macOS 26's backing-view-less glass. - let effect = NSVisualEffectView() - effect.material = .hudWindow - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 12 - effect.layer?.masksToBounds = true - effect.translatesAutoresizingMaskIntoConstraints = false - - // A plain container as the contentView, with the material behind and the SwiftUI - // content in front, both pinned to it. Pinning the hosting view to a plain view - // (not to the material view itself) keeps its layout correct — no clipped header. - let container = NSView() - container.wantsLayer = true - container.addSubview(effect) - container.addSubview(hosting) - NSLayoutConstraint.activate([ - effect.leadingAnchor.constraint(equalTo: container.leadingAnchor), - effect.trailingAnchor.constraint(equalTo: container.trailingAnchor), - effect.topAnchor.constraint(equalTo: container.topAnchor), - effect.bottomAnchor.constraint(equalTo: container.bottomAnchor), - hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), - hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), - hosting.topAnchor.constraint(equalTo: container.topAnchor), - hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - panel.contentView = container - panel.hasShadow = true - return panel - } - - // MARK: Rich editor - - /// Opens the rich `EditItemSheet` for `item` in a full-screen, dim-backed child window - /// centered on the palette's screen — the same modal-host approach as the shelf. The - /// palette panel can't host it (it must stay non-key), so this window becomes key and - /// Copy activates briefly so the editor's text view can take keyboard focus; both are - /// yielded back on dismiss. - private func presentEditor(for item: ClipItem) { - let host = modalPanel ?? makeModalPanel() - host.contentView = NSHostingView(rootView: PasteStackEditorHost( - item: item, - store: model.store, - proDark: proDark, - onCancel: { [weak self] in self?.dismissEditor() }, - onSave: { [weak self] attributed in - self?.model.commitEdit(attributed, for: item) - self?.dismissEditor() - } - )) - if let screen = panel?.screen ?? NSScreen.main { - host.setFrame(screen.frame, display: true) - } - if let panel, host.parent !== panel { - panel.addChildWindow(host, ordered: .above) - } - NSApp.activate(ignoringOtherApps: true) - host.makeKeyAndOrderFront(nil) - } - - /// Tears down the editor window and hands active status back to the app the user was - /// working in, so plain ⌘V resumes pasting there (the palette panel stays non-key). - private func dismissEditor() { - guard let host = modalPanel else { return } - panel?.removeChildWindow(host) - host.orderOut(nil) - NSApp.deactivate() - } - - private func makeModalPanel() -> KeyablePanel { - let host = KeyablePanel(contentRect: .zero, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, defer: false) - host.level = .statusBar - host.isOpaque = false - host.backgroundColor = .clear - host.hasShadow = false - host.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - host.hidesOnDeactivate = false - host.appearance = proDark ? NSAppearance(named: .darkAqua) : nil - host.sharingType = hideDuringScreenSharing ? .none : .readOnly - modalPanel = host - return host - } -} diff --git a/Copy/PasteStack/PasteStackEngine.swift b/Copy/PasteStack/PasteStackEngine.swift deleted file mode 100644 index 7e6af2d..0000000 --- a/Copy/PasteStack/PasteStackEngine.swift +++ /dev/null @@ -1,149 +0,0 @@ -import AppKit -import CopyCore - -/// CGEvent tap that intercepts a plain Command-V while the Paste Stack is active, so -/// pressing Command-V in any frontmost app walks the queue instead of pasting whatever -/// is already on the system pasteboard. Every event this tap sees passes straight -/// through except a bare Command-V (no Shift, no Option, no Control) that doesn't carry -/// our own `selfEventUserData` marker — that one is swallowed here; `AppCoordinator.pasteNextViaEngine()` -/// (wired as `onIntercept`) puts the next stack item on the pasteboard and re-synthesizes -/// a MARKED Command-V via `postMarkedPasteKeystroke()`, so the frontmost app still sees -/// what looks like an ordinary paste, and this tap lets that one through untouched. -/// -/// Creating the tap requires Accessibility permission; `activate()` returns `false` when -/// the OS refuses (no permission, or Secure Input is active), in which case the caller -/// falls back to the `.pasteNextFromStack` hotkey. -final class PasteStackEngine { - /// Marks a synthesized Command-V event as our own so the tap passes it straight - /// through instead of re-intercepting it (which would loop forever). Aliased to - /// `CopyPasteboard.selfEventUserData` so there is exactly one definition shared - /// with every other place Copy synthesizes a ⌘V (e.g. `CGKeyEventPoster`) — this - /// tap must recognize the shelf/menu's own marked pastes too, not just its own. - static let selfEventUserData = CopyPasteboard.selfEventUserData - - private let onIntercept: () -> Void - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - - init(onIntercept: @escaping () -> Void) { - self.onIntercept = onIntercept - } - - /// Belt-and-suspenders: the tap callback holds an *unretained* pointer to this - /// instance (see the trampoline below), so nothing keeps it alive on its own — this - /// localizes the invariant that the tap must never outlive its engine to the engine - /// itself, rather than relying solely on every call site remembering to `deactivate()`. - deinit { - deactivate() - } - - /// Creates and enables the event tap. Returns `false` when the tap couldn't be - /// created — most commonly because Accessibility hasn't been granted — in which - /// case the caller is expected to fall back to the `.pasteNextFromStack` hotkey. - /// Safe to call again while already active (returns `true` without recreating it). - @discardableResult - func activate() -> Bool { - if eventTap != nil { return true } - - let selfPointer = Unmanaged.passUnretained(self).toOpaque() - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: (1 << CGEventType.keyDown.rawValue), - callback: pasteStackEventTapCallback, - userInfo: selfPointer - ) else { - return false - } - - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) - - eventTap = tap - runLoopSource = source - return true - } - - /// Disables and tears down the tap. Safe to call when already inactive (including - /// repeatedly, e.g. from both the palette's close path and app termination). - func deactivate() { - guard let tap = eventTap else { return } - CGEvent.tapEnable(tap: tap, enable: false) - if let source = runLoopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) - } - CFMachPortInvalidate(tap) - eventTap = nil - runLoopSource = nil - } - - /// Posts a Command-V keystroke marked with `selfEventUserData` so this tap (or any - /// other Paste Stack engine instance) lets it straight through. Called by - /// `AppCoordinator.pasteNextViaEngine()` once the next stack item is on the - /// pasteboard, so the frontmost app receives what looks like an ordinary paste. - /// Only used on the Accessibility-backed path — the `.pasteNextFromStack` fallback - /// hotkey relies on the user's own ⌘V instead (see `AppCoordinator.pasteNextFromStack()`). - static func postMarkedPasteKeystroke() { - let source = CGEventSource(stateID: .combinedSessionState) - let vKey: CGKeyCode = 9 // kVK_ANSI_V - let keyDown = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true) - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) - keyDown?.flags = .maskCommand - keyUp?.flags = .maskCommand - keyDown?.setIntegerValueField(.eventSourceUserData, value: selfEventUserData) - keyUp?.setIntegerValueField(.eventSourceUserData, value: selfEventUserData) - keyDown?.post(tap: .cghidEventTap) - keyUp?.post(tap: .cghidEventTap) - } - - /// The tap callback's actual logic, invoked by the C trampoline below. Runs on the - /// main run loop (the source is added to `CFRunLoopGetMain()`), but stays lean per - /// Apple's event-tap guidance — the real work happens in `onIntercept`, dispatched - /// asynchronously so this callback returns immediately. - fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let tap = eventTap { - CGEvent.tapEnable(tap: tap, enable: true) - } - return Unmanaged.passUnretained(event) - } - guard type == .keyDown else { return Unmanaged.passUnretained(event) } - guard event.getIntegerValueField(.eventSourceUserData) != Self.selfEventUserData else { - return Unmanaged.passUnretained(event) // our own synthesized ⌘V: pass through - } - guard event.getIntegerValueField(.keyboardEventAutorepeat) == 0 else { - // Holding ⌘V down auto-repeats keyDown events; intercepting each one would - // drain the queue far faster than the user intended. Only a fresh press - // advances the stack. - return Unmanaged.passUnretained(event) - } - - let flags = event.flags - let isPlainCommandV = flags.contains(.maskCommand) - && event.getIntegerValueField(.keyboardEventKeycode) == 9 - && !flags.contains(.maskShift) - && !flags.contains(.maskAlternate) - && !flags.contains(.maskControl) - guard isPlainCommandV else { return Unmanaged.passUnretained(event) } - - // Consume the original ⌘V; on main, place the next stack item on the - // pasteboard, then synthesize a marked ⌘V so the frontmost app pastes it. - DispatchQueue.main.async { [onIntercept] in onIntercept() } - return nil - } -} - -/// C-callable trampoline for `CGEvent.tapCreate`. The callback type can't capture -/// state, so `userInfo` carries an unretained pointer to the engine instance instead. -private func pasteStackEventTapCallback( - proxy: CGEventTapProxy, - type: CGEventType, - event: CGEvent, - userInfo: UnsafeMutableRawPointer? -) -> Unmanaged? { - guard let userInfo else { return Unmanaged.passUnretained(event) } - let engine = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() - return engine.handle(type: type, event: event) -} diff --git a/Copy/PasteStack/PasteStackModel.swift b/Copy/PasteStack/PasteStackModel.swift deleted file mode 100644 index 72af89e..0000000 --- a/Copy/PasteStack/PasteStackModel.swift +++ /dev/null @@ -1,111 +0,0 @@ -import AppKit -import CopyCore -import Foundation -import Observation - -/// Backs the Paste Stack palette (`PasteStackController`/`PasteStackView`): owns the -/// pure `PasteStackQueue` plus whether the palette is currently active, and resolves -/// queued uuids to `ClipItem`s/representations via the store. -@MainActor -@Observable -final class PasteStackModel { - var queue = PasteStackQueue() - - /// Whether the palette should be on screen. This is the single source of truth for - /// palette visibility: every code path that wants to show or hide the palette does - /// it by setting this property, never by calling the controller directly. - /// `AppCoordinator` wires `onActiveChange` to `PasteStackController.syncVisibility(to:)`, - /// so the `didSet` below is the one place that decision fans out from. - var isActive = false { - didSet { - guard isActive != oldValue else { return } - onActiveChange?(isActive) - } - } - - let store: ItemStore - - /// Bumped after an in-place edit so `PasteStackView` re-renders (the queue's uuids - /// don't change on an edit, so nothing else would trigger a refresh). - var revision = 0 - - @ObservationIgnored var onActiveChange: ((Bool) -> Void)? - - init(store: ItemStore) { - self.store = store - } - - /// CRUD "create": adds the most recent captured item that isn't already queued. - func addMostRecent() { - guard let latest = (try? store.recentItems(limit: 30))? - .first(where: { !queue.itemUUIDs.contains($0.uuid) }) else { return } - queue.enqueue(latest.uuid) - } - - /// CRUD "update": saves edited rich text from the `EditItemSheet` opened by the row's - /// pencil, mirroring `ShelfViewModel.commitEdit`. Writes both a `public.rtf` - /// representation and canonical plain text (RTF encoding happens here, the app layer; - /// CopyCore stays Foundation-only and takes pre-encoded `Data`). Editing the shared - /// `ClipItem` updates it everywhere (history too), the expected behavior for the same - /// item; the `revision` bump re-renders the palette since the queue's uuids don't change. - func commitEdit(_ attributed: NSAttributedString, for item: ClipItem) { - guard let id = item.id else { return } - let plainText = attributed.string - guard let rtfData = attributed.rtf( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [:] - ) else { - NSLog("Copy: failed to RTF-encode edited paste-stack item") - return - } - do { - try store.replaceContent(itemID: id, rtfData: rtfData, plainText: plainText) - revision += 1 - } catch { - NSLog("Copy: failed to save edited paste-stack item: \(error)") - } - } - - /// Resolves queued uuids to `ClipItem`s, preserving queue order. This is a PURE - /// read: a uuid that no longer resolves to a stored item (deleted or pruned since - /// being queued) is simply omitted from the returned array, and `queue` itself is - /// never mutated here. SwiftUI calls this from view bodies (`PasteStackView` - /// resolves it once per body evaluation) — mutating observed state (`queue`) while - /// a body is evaluating is undefined behavior, so stale-uuid cleanup happens only - /// at explicit event points via `reconcile()`, never as a side effect of reading. - func items() -> [ClipItem] { - queue.itemUUIDs.compactMap { try? store.item(uuid: $0) } - } - - /// Drops any queued uuid that no longer resolves to a stored item. Call this at - /// explicit event points — never from `items()` — such as `PasteStackController` - /// showing the palette, so the queue doesn't accumulate orphaned uuids forever. - func reconcile() { - for uuid in queue.itemUUIDs where (try? store.item(uuid: uuid)) == nil { - queue.remove(uuid) - } - } - - /// Enqueues `item`, activating the palette if it wasn't already. - func enqueue(_ item: ClipItem) { - queue.enqueue(item.uuid) - if !isActive { isActive = true } - } - - /// Advances the queue and resolves representations for the next item, skipping - /// (and retrying) any uuid whose item or representations have gone missing since - /// being queued. Returns `nil` once the queue is exhausted. Unlike `items()`, this - /// runs from an explicit event handler (the paste engine, in Task 7), not from a - /// SwiftUI body — `queue.advance()`'s mutation is safe here. - func advanceAndResolve() -> [CapturedRepresentation]? { - while let uuid = queue.advance() { - guard let id = (try? store.item(uuid: uuid))?.id, - let reps = try? store.representations(forItemID: id), - !reps.isEmpty else { - continue - } - return reps - } - return nil - } -} diff --git a/Copy/PasteStack/PasteStackView.swift b/Copy/PasteStack/PasteStackView.swift deleted file mode 100644 index ef44349..0000000 --- a/Copy/PasteStack/PasteStackView.swift +++ /dev/null @@ -1,308 +0,0 @@ -import AppKit -import CopyCore -import SwiftUI - -/// Content of the floating Paste Stack palette. The palette is a non-activating, never-key -/// panel (so plain ⌘V keeps pasting into the app underneath — see `PasteStackEngine`), and -/// its panel `contentView` is a real `NSVisualEffectView` that captures every click, so the -/// SwiftUI here can stay simple. Rows are a fixed-height `ScrollView`; reorder is a direct -/// drag gesture (no drag-and-drop system, which needs window focus). Entries can be added -/// (header +, queues the latest copy), edited in the rich `EditItemSheet` (double-click / -/// pencil, via `onEdit` → `PasteStackController.presentEditor`), removed (trash on hover / -/// context menu), reordered by dragging, and cleared (header trash). -struct PasteStackView: View { - @Bindable var model: PasteStackModel - var onClose: () -> Void = {} - var onEdit: (ClipItem) -> Void = { _ in } - var onContentChange: () -> Void = {} - - @State private var draggingUUID: String? - @State private var dragTranslation: CGFloat = 0 - - static let rowHeight: CGFloat = 36 - - /// Reads `model.revision` (so an in-place edit re-renders) and resolves the queue's - /// uuids to items once per body evaluation. - private var resolvedItems: [ClipItem] { - _ = model.revision - return model.items() - } - - var body: some View { - let items = resolvedItems - VStack(spacing: 0) { - header(count: items.count) - Divider().opacity(0.6) - Group { - if items.isEmpty { - emptyState - } else { - list(items: items) - } - } - .frame(maxHeight: .infinity) - if !items.isEmpty { - Divider().opacity(0.6) - footer - } - } - // Top-anchored: the header stays pinned to the top no matter how the panel height - // and the content height drift — any mismatch shows as space below the footer, never - // a clipped header. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onChange(of: model.queue.itemUUIDs) { _, _ in onContentChange() } - } - - // MARK: Header - - private func header(count: Int) -> some View { - HStack(spacing: 8) { - Image(systemName: "square.stack.3d.up") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - Text("Paste Stack") - .font(.system(size: 13, weight: .semibold)) - if count > 0 { - Text("\(count)") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Capsule().fill(Color(nsColor: .quaternaryLabelColor).opacity(0.5))) - } - Spacer(minLength: 8) - if count > 0 { - IconButton(systemName: "trash", help: "Clear the stack") { model.queue.clear() } - } - IconButton(systemName: "plus", help: "Add the latest copy") { model.addMostRecent() } - IconButton(systemName: "xmark", help: "Close") { onClose() } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - // The header is the palette's drag handle; the buttons on top take their own clicks. - .background(WindowDragArea()) - } - - // MARK: Empty state - - private var emptyState: some View { - VStack(spacing: 6) { - Image(systemName: "square.stack") - .font(.system(size: 26, weight: .light)) - .foregroundStyle(.tertiary) - Text("Nothing queued") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - Text("Add a copy with +, then ⌘V walks the stack.") - .font(Tokens.caption) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .padding(.horizontal, 16) - } - - // MARK: List - - private func list(items: [ClipItem]) -> some View { - let order = pasteNumbers(for: items) - return ScrollView { - VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.uuid) { index, item in - PasteStackRow( - item: item, - number: order[item.uuid] ?? 0, - isNext: order[item.uuid] == 1, - isDragging: draggingUUID == item.uuid, - isEditable: isEditable(item), - onEdit: { onEdit(item) }, - onRemove: { model.queue.remove(item.uuid) } - ) - .frame(height: Self.rowHeight) - .offset(y: dragYOffset(uuid: item.uuid, index: index, items: items)) - .zIndex(draggingUUID == item.uuid ? 1 : 0) - .gesture(reorderGesture(item: item, items: items)) - .contextMenu { - if isEditable(item) { - Button("Edit…") { onEdit(item) } - } - Button("Remove", role: .destructive) { model.queue.remove(item.uuid) } - } - } - } - .padding(.vertical, 4) - } - } - - // MARK: Reorder (direct drag, no drag-and-drop system) - - private func reorderGesture(item: ClipItem, items: [ClipItem]) -> some Gesture { - DragGesture(minimumDistance: 6, coordinateSpace: .local) - .onChanged { value in - if draggingUUID == nil { draggingUUID = item.uuid } - dragTranslation = value.translation.height - } - .onEnded { _ in - defer { draggingUUID = nil; dragTranslation = 0 } - guard let dragging = draggingUUID, - let source = items.firstIndex(where: { $0.uuid == dragging }) else { return } - let target = clampedTarget(source: source, count: items.count) - if target != source { model.queue.move(from: source, to: target) } - } - } - - private func clampedTarget(source: Int, count: Int) -> Int { - let delta = Int((dragTranslation / Self.rowHeight).rounded()) - return min(max(source + delta, 0), count - 1) - } - - private func dragYOffset(uuid: String, index: Int, items: [ClipItem]) -> CGFloat { - guard let dragging = draggingUUID, - let source = items.firstIndex(where: { $0.uuid == dragging }) else { return 0 } - if uuid == dragging { return dragTranslation } - let target = clampedTarget(source: source, count: items.count) - if source < target, index > source, index <= target { return -Self.rowHeight } - if source > target, index < source, index >= target { return Self.rowHeight } - return 0 - } - - // MARK: Edit - - private func isEditable(_ item: ClipItem) -> Bool { - switch item.kind { - case .text, .richText, .link: return true - case .image, .file, .color: return false - } - } - - /// Maps each item's uuid to its 1-based paste position. Row 1 is whatever Command V - /// pastes next: the first-added under "Oldest first" (FIFO) or the last-added under - /// "Newest first" (LIFO). `model.items()` is in insertion order, so LIFO numbers from - /// the bottom up. - private func pasteNumbers(for items: [ClipItem]) -> [String: Int] { - let isLIFO = model.queue.isLIFO - var map: [String: Int] = [:] - for (index, item) in items.enumerated() { - map[item.uuid] = isLIFO ? (items.count - index) : (index + 1) - } - return map - } - - // MARK: Footer - - private var footer: some View { - VStack(spacing: 8) { - Picker("Paste order", selection: $model.queue.isLIFO) { - Text("Oldest first").tag(false) - Text("Newest first").tag(true) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity) - .accessibilityLabel("Paste order") - - Text("⌘V pastes the next item · drag to reorder") - .font(Tokens.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - } -} - -private struct PasteStackRow: View { - let item: ClipItem - let number: Int - let isNext: Bool - var isDragging: Bool = false - var isEditable: Bool = true - let onEdit: () -> Void - let onRemove: () -> Void - - @State private var isHovering = false - - var body: some View { - HStack(spacing: 10) { - Text("\(number)") - .font(.system(size: 11, weight: .semibold, design: .rounded)) - .foregroundStyle(isNext ? Color.white : Color.secondary) - .frame(width: 20, height: 20) - .background( - Circle().fill(isNext ? Color.accentColor : Color(nsColor: .quaternaryLabelColor).opacity(0.5)) - ) - - Image(systemName: glyph) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .frame(width: 16) - - Text(item.displayTitle) - .font(Tokens.bodyMono) - .lineLimit(1) - - Spacer(minLength: 0) - - if isHovering { - HStack(spacing: 1) { - // Only text-like rows can be edited; images/files/colors show just Remove. - if isEditable { - IconButton(systemName: "pencil", fontSize: 10, - size: CGSize(width: 22, height: 22), help: "Edit", action: onEdit) - } - IconButton(systemName: "trash", fontSize: 10, - size: CGSize(width: 22, height: 22), help: "Remove", action: onRemove) - } - } else if isNext { - Text("Next") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color.accentColor.opacity(0.14))) - } - } - .padding(.horizontal, 10) - .frame(maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(isDragging ? Color.primary.opacity(0.1) : (isHovering ? Color.primary.opacity(0.05) : .clear)) - ) - .shadow(color: isDragging ? .black.opacity(0.2) : .clear, radius: isDragging ? 6 : 0, y: 2) - .padding(.horizontal, 6) - .contentShape(Rectangle()) - .onHover { isHovering = $0 } - .onTapGesture(count: 2) { if isEditable { onEdit() } } - } - - private var glyph: String { - switch item.kind { - case .text, .richText: return "text.alignleft" - case .link: return "link" - case .image: return "photo" - case .file: return "doc" - case .color: return "paintpalette" - } - } -} - -/// Full-screen host for the paste-stack rich editor: a dim backdrop with the shared -/// `EditItemSheet` centered on top, mirroring `ShelfModalHostView`. Lives in the -/// controller's key-capable child window (`PasteStackController.presentEditor`) so the -/// editor's text view can take focus without the palette panel ever becoming key. -struct PasteStackEditorHost: View { - let item: ClipItem - let store: ItemStore - var proDark: Bool - let onCancel: () -> Void - let onSave: (NSAttributedString) -> Void - - var body: some View { - ZStack { - Color.black.opacity(0.32) - .ignoresSafeArea() - EditItemSheet(item: item, store: store, onCancel: onCancel, onSave: onSave) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .tint(proDark ? Tokens.electricBlue : nil) - } -} diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 805d6ae..288652c 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -36,7 +36,7 @@ struct GeneralSettings: View { Section { Toggle("Always Use Dark Shelf", isOn: $settings.shelfProDark) } footer: { - Text("Keeps the shelf and Paste Stack dark with a blue accent, even in Light Mode. Off by default, so they follow your system appearance.") + Text("Keeps the shelf dark with a blue accent, even in Light Mode. Off by default, so it follows your system appearance.") .font(.footnote) .foregroundStyle(.secondary) } diff --git a/Copy/Settings/HistorySettings.swift b/Copy/Settings/HistorySettings.swift index d0374ab..8024e1e 100644 --- a/Copy/Settings/HistorySettings.swift +++ b/Copy/Settings/HistorySettings.swift @@ -55,8 +55,8 @@ struct HistorySettings: View { private var retentionFooter: String { if settings.retention == .unlimited { - return "Nothing is removed by age. Favorites and pinboard items are always kept." + return "Nothing is removed by age." } - return "Items you haven't used in \(settings.retention.title.lowercased()) are removed. Favorites and pinboard items are always kept." + return "Items you haven't used in \(settings.retention.title.lowercased()) are removed. Pinboard items are always kept." } } diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index ad19730..aec0da8 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -27,7 +27,7 @@ enum CopySound: String, CaseIterable, Identifiable { } } -/// How long unfavorited, unpinned history items are kept before pruning. +/// How long unpinned history items are kept before pruning. enum RetentionPeriod: String, CaseIterable { case unlimited case day @@ -78,13 +78,10 @@ final class SettingsStore { private static let legacyMigrationKey = "didMigrateSettingsFromOriginalCopy" private static let legacyKeys = [ "KeyboardShortcuts_toggleShelf", - "KeyboardShortcuts_togglePasteStack", - "KeyboardShortcuts_pasteNextFromStack", "KeyboardShortcuts_quickPasteLatest", "KeyboardShortcuts_nextPinboard", "hasOnboarded", "hasSeenFirstCopyCoach", - "hasSeenPasteStackHint", retentionKey, fetchLinkPreviewsKey, recognizeImageTextKey, @@ -136,10 +133,8 @@ final class SettingsStore { } } - /// When true, the shelf panel and Paste Stack palette set `NSWindowSharingType.none` - /// so they're excluded from screen recordings/captures/shares (see - /// `ShelfPanelController.setHideDuringScreenSharing`/`PasteStackController`'s - /// equivalent). Defaults to false: Copy is visible in screenshots and recordings + /// When true, the shelf panel sets `NSWindowSharingType.none` so it's excluded from + /// screen recordings/captures/shares. Defaults to false: Copy is visible in screenshots and recordings /// out of the box (so people can capture and share it), and hiding is opt-in. var hideDuringScreenSharing: Bool { didSet { @@ -162,11 +157,11 @@ final class SettingsStore { } } - /// A fixed "pro dark" look for the shelf and paste stack: a forced dark appearance + /// A fixed "pro dark" look for the shelf: a forced dark appearance /// plus an electric-blue accent, regardless of the system appearance or accent color /// (so the app matches its own marketing look). Off by default, so the shelf follows - /// the system otherwise. `onShelfProDarkChange` pushes it to the panel controllers - /// (which set the window appearance live); `ShelfRootView` reads it via + /// the system otherwise. `onShelfProDarkChange` pushes it to the panel controller + /// (which sets the window appearance live); `ShelfRootView` reads it via /// `ShelfViewModel.settings` to apply the tint. var shelfProDark: Bool { didSet { diff --git a/Copy/Settings/SettingsView.swift b/Copy/Settings/SettingsView.swift index a39050f..7ed3d3e 100644 --- a/Copy/Settings/SettingsView.swift +++ b/Copy/Settings/SettingsView.swift @@ -8,7 +8,7 @@ import SwiftUI /// The window is tinted electric-blue (`Tokens.electricBlue`) so the sidebar selection and /// all controls read as one brand accent, but it otherwise stays native: it follows the /// system appearance and each pane's controls sit in a grouped `Form`. That last choice is -/// deliberate and unchanged from the tabbed version — unlike the shelf/paste stack/sheets, +/// deliberate and unchanged from the tabbed version — unlike the shelf and sheets, /// Settings keeps its content on the opaque grouped-form background rather than glass, the /// same way Apple's System Settings reserves glass for chrome (the sidebar) and not the /// settings content itself. diff --git a/Copy/Settings/ShortcutsSettings.swift b/Copy/Settings/ShortcutsSettings.swift index 2edb8cd..5fd233a 100644 --- a/Copy/Settings/ShortcutsSettings.swift +++ b/Copy/Settings/ShortcutsSettings.swift @@ -15,7 +15,6 @@ struct ShortcutsSettings: View { KeyboardShortcuts.Recorder("Open Copy:", name: .toggleShelf) { _ in settings.onShelfHotkeyChange?() } - KeyboardShortcuts.Recorder("Paste Stack:", name: .togglePasteStack) KeyboardShortcuts.Recorder("Quick Paste Latest:", name: .quickPasteLatest) KeyboardShortcuts.Recorder("Next Pinboard:", name: .nextPinboard) } footer: { diff --git a/Copy/Settings/StorageUsageSection.swift b/Copy/Settings/StorageUsageSection.swift index 830df33..8cef5e7 100644 --- a/Copy/Settings/StorageUsageSection.swift +++ b/Copy/Settings/StorageUsageSection.swift @@ -3,8 +3,7 @@ import SwiftUI /// The History pane's storage block: a total, a proportional stacked bar, per-type rows /// (with a per-type "Clear"), and a prominent "Clear History" button. Reads -/// `ItemStore.storageBreakdown()` (the *clearable* set: -/// favorites and pinboard items are permanent and excluded) and clears through the store, +/// `ItemStore.storageBreakdown()` (the clearable set excludes pinboard items) and clears through the store, /// which the shelf observes live via GRDB, so an open shelf refreshes on its own. struct StorageUsageSection: View { let store: ItemStore @@ -26,7 +25,7 @@ struct StorageUsageSection: View { } header: { Text("Storage") } footer: { - Text("Favorites and pinboard items are kept and aren't counted here.") + Text("Pinboard items are kept and aren't counted here.") .font(.footnote) .foregroundStyle(.secondary) } @@ -52,7 +51,7 @@ struct StorageUsageSection: View { Button("Clear History", role: .destructive) { clearAll() } Button("Cancel", role: .cancel) {} } message: { - Text("Favorites and pinboard items are kept. This cannot be undone.") + Text("Pinboard items are kept. This cannot be undone.") } .confirmationDialog( pendingKindClear.map { "Clear all \($0.label.lowercased()) from history?" } ?? "", @@ -62,7 +61,7 @@ struct StorageUsageSection: View { Button("Clear \(category.label)", role: .destructive) { clear(category: category) } Button("Cancel", role: .cancel) {} } message: { _ in - Text("Favorites and pinboard items are kept. This cannot be undone.") + Text("Pinboard items are kept. This cannot be undone.") } } @@ -132,7 +131,7 @@ struct StorageUsageSection: View { } private func clearAll() { - try? store.clearHistory(keepFavorites: true) + try? store.clearHistory() reload() } diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index 36aa0b8..5172c18 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -40,10 +40,8 @@ struct ItemCardView: View { let onBeginInlineRename: () -> Void let onCommitInlineRename: (String) -> Void let onCancelInlineRename: () -> Void - let onToggleFavorite: () -> Void let onAddToPinboard: (Int64) -> Void let onRemoveFromPinboard: () -> Void - let onAddToPasteStack: () -> Void let onCopyText: () -> Void let onQuickLook: () -> Void let onOpen: () -> Void @@ -159,24 +157,15 @@ struct ItemCardView: View { } } .overlay(alignment: .topTrailing) { - // On hover, surface the two most-buried card actions (favorite, delete) as a - // floating pill so they're discoverable without opening the context menu. - // Otherwise, just the quiet favorite indicator when the card is favorited. + // Surface destructive/organizational actions without crowding every card. if isHovering && !isInlineRenaming { - CardHoverActions(isFavorite: item.isFavorite, - onToggleFavorite: onToggleFavorite, + CardHoverActions( // Only offer unpin while viewing a pinboard, where the card // actually belongs to one it can be removed from. onUnpin: currentPinboardID != nil ? onRemoveFromPinboard : nil, onDelete: onDelete) .padding(5) .transition(.opacity) - } else if item.isFavorite { - Image(systemName: "star.fill") - .font(.system(size: 9)) - .foregroundStyle(.yellow) - .padding(6) - .accessibilityLabel("Favorite") } } .onHover { hovering in @@ -227,7 +216,6 @@ struct ItemCardView: View { Button("Adjust Color…", action: onAdjustColor) } Button("Rename…", action: onBeginInlineRename) - Button(item.isFavorite ? "Unfavorite" : "Favorite", action: onToggleFavorite) Menu("Add to Pinboard") { if pinboards.isEmpty { Text("No Pinboards") @@ -241,7 +229,6 @@ struct ItemCardView: View { } } } - Button("Add to Paste Stack", action: onAddToPasteStack) if currentPinboardID != nil { Button("Remove from Pinboard", action: onRemoveFromPinboard) } @@ -653,12 +640,9 @@ private struct CardClickGesture: ViewModifier { } /// The floating action pill shown on card hover (see `ItemCardView`'s top-trailing -/// overlay). Surfaces favorite and delete, the two high-value actions otherwise hidden -/// in the right-click menu, plus unpin while viewing a pinboard. Stays a quiet affordance +/// overlay). Surfaces delete plus unpin while viewing a pinboard. Stays a quiet affordance /// rather than a toolbar; the rest remains in the context menu and via drag. private struct CardHoverActions: View { - let isFavorite: Bool - let onToggleFavorite: () -> Void /// Removes the card from the pinboard currently being viewed. `nil` (and so hidden) /// outside a pinboard, where there's nothing to unpin from. let onUnpin: (() -> Void)? @@ -666,11 +650,6 @@ private struct CardHoverActions: View { var body: some View { HStack(spacing: 1) { - IconButton(systemName: isFavorite ? "star.fill" : "star", - fontSize: 11, size: CGSize(width: 22, height: 22), - tint: isFavorite ? .yellow : .secondary, - help: isFavorite ? "Remove from favorites" : "Favorite", - action: onToggleFavorite) if let onUnpin { IconButton(systemName: "pin.slash", fontSize: 11, size: CGSize(width: 22, height: 22), diff --git a/Copy/Shelf/PreviewPane.swift b/Copy/Shelf/PreviewPane.swift index 7c039ae..f4276c8 100644 --- a/Copy/Shelf/PreviewPane.swift +++ b/Copy/Shelf/PreviewPane.swift @@ -186,7 +186,7 @@ struct PreviewPane: View { // M7: the popover's own chrome is otherwise unstyled (SwiftUI/AppKit gives it // a plain system background), so this is a clean single-surface adoption — // glass on 26 with Reduce Transparency off, the app's existing `.hudWindow` - // material otherwise. `clipShape` mirrors `PasteStackView`'s treatment so the + // material otherwise. `clipShape` ensures the // `ScrollView` text case (the `default` branch above) doesn't bleed square // corners past the rounded backing on either code path. .glassSurface(cornerRadius: 12) diff --git a/Copy/Shelf/SearchTokenField.swift b/Copy/Shelf/SearchTokenField.swift index 5e74477..99181cd 100644 --- a/Copy/Shelf/SearchTokenField.swift +++ b/Copy/Shelf/SearchTokenField.swift @@ -52,7 +52,7 @@ struct SearchTokenField: View { } /// Shown when the empty search field is focused: a compact legend of the filter categories -/// so users discover they can filter by app, type, time, favorites, and pinboards, not just +/// so users discover they can filter by app, type, time, and pinboards, not just /// search text. private struct SearchHintPanel: View { var body: some View { @@ -63,7 +63,6 @@ private struct SearchHintPanel: View { .tracking(0.5) row("textformat", "Type", "Text, Link, Image, File, Color") row("calendar", "Time", "Today, Last week, Last month…") - row("star", "Favorites", "Only starred items") row("app.dashed", "App", "Name an app you copied from") row("pin", "Pinboard", "Name one of your boards") } diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index d904ef1..3505c6a 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -6,7 +6,7 @@ final class KeyablePanel: NSPanel { /// SwiftUI's `.popover()` presents its content in a separate `NSWindow` attached to /// this one via `addChildWindow` — a different window than the panel itself, so /// merely setting the panel's own `sharingType` doesn't exclude it from capture. - /// Whoever owns this panel (`ShelfPanelController`/`PasteStackController`) keeps this + /// `ShelfPanelController` keeps this /// in sync with its own `sharingType` policy; every child window attached from then /// on inherits it here, before `super.addChildWindow` orders it on screen. var childWindowSharingType: NSWindow.SharingType = .readOnly diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index f24d740..ebb1c51 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -1,7 +1,6 @@ import SwiftUI import CopyCore import AppKit -import KeyboardShortcuts import UniformTypeIdentifiers struct ShelfRootView: View { @@ -162,7 +161,6 @@ private struct ShelfHeader: View { Spacer(minLength: 12) SearchTokenField(viewModel: viewModel, placeholder: searchPlaceholder, focused: $searchFocused) .frame(maxWidth: 360) - PasteStackButton(viewModel: viewModel) DrawerMenu(viewModel: viewModel) } .padding(.horizontal, Tokens.shelfPadding) @@ -188,33 +186,6 @@ private struct ShelfHeader: View { } } -/// Header control that opens the Paste Stack palette (`onTogglePasteStack`), tinting to the -/// accent while the stack is active. While ⌘ is held it reveals the stack's rebindable -/// hotkey as a `KeyCap`, matching the shelf's other ⌘-hold hints (the card number badges). -private struct PasteStackButton: View { - @Bindable var viewModel: ShelfViewModel - - private var hotkey: String { - KeyboardShortcuts.getShortcut(for: .togglePasteStack)?.description ?? "⇧⌘C" - } - - var body: some View { - HStack(spacing: 5) { - IconButton(systemName: "square.stack.3d.up", fontSize: 15, - size: CGSize(width: 34, height: 30), - tint: viewModel.isPasteStackOn ? Color.accentColor : .secondary, - help: "Paste Stack (\(hotkey))") { - viewModel.onTogglePasteStack?() - } - if viewModel.commandHeld { - KeyCap(text: hotkey) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - } - .animation(.easeOut(duration: 0.12), value: viewModel.commandHeld) - } -} - /// The shelf's own "more" control — a discreet ellipsis button mirroring every /// status-menu action, so the app is fully usable with the menu bar icon hidden /// (`SettingsStore.hideMenuBarIcon`). Deliberately quiet (borderless, no chevron, @@ -243,7 +214,6 @@ private struct DrawerMenu: View { menu.addItem(item) } add("New Item…") { viewModel.onNewItem?() } - add("Paste Stack", checked: viewModel.isPasteStackOn) { viewModel.onTogglePasteStack?() } add(viewModel.isPrivacyModeOn ? "Resume Monitoring" : "Pause Monitoring") { viewModel.onTogglePrivacyMode?() } menu.addItem(.separator()) add("Clear History…") { viewModel.onClearHistory?() } @@ -494,12 +464,6 @@ private struct ShelfItemsRow: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: Tokens.cardGap(compact: compact)) { ForEach(Array(viewModel.items.enumerated()), id: \.element.uuid) { index, item in - // A divider between the leading favorites and the rest. - if index == viewModel.favoritesCount, - viewModel.favoritesCount > 0, - viewModel.favoritesCount < viewModel.items.count { - FavoritesDivider(compact: compact) - } ItemCardView( item: item, isSelected: viewModel.isSelected(item), @@ -527,14 +491,12 @@ private struct ShelfItemsRow: View { onBeginInlineRename: { viewModel.beginInlineRename(item) }, onCommitInlineRename: { viewModel.commitInlineRename(item, to: $0) }, onCancelInlineRename: { viewModel.cancelInlineRename() }, - onToggleFavorite: { viewModel.toggleFavorite(item) }, onAddToPinboard: { id in viewModel.addItem(item, toPinboard: id) }, onRemoveFromPinboard: { if let currentPinboardID { viewModel.removeItem(item, fromPinboard: currentPinboardID) } }, - onAddToPasteStack: { viewModel.addToPasteStack(item) }, onCopyText: { viewModel.copyText(item) }, onQuickLook: { viewModel.quickLook(item) }, onOpen: { viewModel.open(item) }, @@ -582,26 +544,6 @@ private struct ShelfItemsRow: View { } } -/// The vertical rule between the leading favorited cards and the rest of the shelf, -/// topped with a small star so the grouping reads at a glance. -private struct FavoritesDivider: View { - var compact: Bool = false - - var body: some View { - VStack(spacing: 5) { - Image(systemName: "star.fill") - .font(.system(size: 9)) - .foregroundStyle(.secondary) - RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(Color(nsColor: .separatorColor).opacity(0.8)) - .frame(width: 1.5) - } - .frame(height: Tokens.cardHeight(compact: compact)) - .padding(.horizontal, 2) - .accessibilityLabel("Favorites divider") - } -} - /// Empty state for the items row, tailored to the active tab and search state. private struct ShelfEmptyState: View { let viewModel: ShelfViewModel diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index e62301b..6758c36 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -30,9 +30,6 @@ final class ShelfViewModel { let settings: SettingsStore var items: [ClipItem] = [] - /// How many leading items in `items` are favorites; the shelf draws a divider after - /// this many cards to separate starred copies from the rest. - var favoritesCount = 0 /// The faceted search: facet pill tokens + trailing free text. Driven through the /// mutating helpers (`updateSearchText`, `acceptSuggestion`, …) rather than a `didSet`, /// so a token-plus-text change refreshes once. @@ -93,14 +90,9 @@ final class ShelfViewModel { /// the shelf's global shortcuts. var inlineRenamingItemID: Int64? - /// Mirror `AppCoordinator.isPaused`/`isPasteStackActive` for the in-drawer menu - /// (`ShelfHeader`'s ellipsis menu), which needs to show "Pause"/"Resume Monitoring" - /// and a Paste Stack checkmark that reflect those AppKit-owned coordinator states. - /// `AppCoordinator` pushes these live whenever the underlying state changes - /// (`togglePause()`, `pasteStackModel.onActiveChange`), the same fan-out shape as - /// `onCompactShelfChange` etc. keep `SettingsStore` in sync with AppKit-side state. + /// Mirrors `AppCoordinator.isPaused` for the in-drawer menu, which needs to show + /// "Pause" or "Resume Monitoring" as that AppKit-owned state changes. var isPrivacyModeOn = false - var isPasteStackOn = false /// Backs `ShelfRootView`'s permission banner. The shelf panel + its SwiftUI content /// are created once and reused for the app's lifetime (see @@ -112,7 +104,6 @@ final class ShelfViewModel { @ObservationIgnored var onPaste: ((ClipItem, Bool) -> Void)? @ObservationIgnored var onPasteMultiple: ((String) -> Void)? - @ObservationIgnored var onAddToPasteStack: ((ClipItem) -> Void)? @ObservationIgnored var onCopyText: ((String) -> Void)? @ObservationIgnored var onAdjustColorCopy: ((String) -> Void)? /// Opens a resolved link/file URL in its default app. `AppCoordinator` wires this to @@ -129,7 +120,6 @@ final class ShelfViewModel { /// so `AppCoordinator` can show/hide the centered modal child window. @ObservationIgnored var onModalPresent: ((Bool) -> Void)? @ObservationIgnored var onNewItem: (() -> Void)? - @ObservationIgnored var onTogglePasteStack: (() -> Void)? @ObservationIgnored var onTogglePrivacyMode: (() -> Void)? @ObservationIgnored var onClearHistory: (() -> Void)? @ObservationIgnored var onExportHistory: (() -> Void)? @@ -228,15 +218,8 @@ final class ShelfViewModel { /// query against it. Each card calls this from `.onAppear`; `PageWindow` decides when a /// wider fetch is actually warranted (and when the history has run out). func loadMoreIfNeeded(at index: Int) { - // `items` is favorites-then-recents, but the window only bounds the recents — the - // store returns every matching favorite regardless of `limit`. Measure in - // recents-space so a big favorites block can't read as "this page came back full" - // and keep growing the window against an already-exhausted history. A favorite's - // own card yields a negative index here and never trips the lookahead, which is - // right: favorites sit at the front, nowhere near the oldest card. guard isPaged, - page.growIfNeeded(visibleIndex: index - favoritesCount, - loadedCount: items.count - favoritesCount) else { return } + page.growIfNeeded(visibleIndex: index, loadedCount: items.count) else { return } startObservation() } @@ -498,17 +481,6 @@ final class ShelfViewModel { if !searchQuery.isEmpty { refresh() } } - func toggleFavoritePrimary() { - guard let item = primaryItem, let id = item.id else { return } - do { - try store.setFavorite(itemID: id, !item.isFavorite) - } catch { - NSLog("Copy: failed to toggle favorite: \(error)") - HUD.show("Couldn't complete that") - } - if !searchQuery.isEmpty { refresh() } - } - func addSelection(toPinboard id: Int64) { for item in orderedSelectedItems { guard let itemID = item.id else { continue } @@ -523,10 +495,6 @@ final class ShelfViewModel { // MARK: - Per-item actions (context menu) - func addToPasteStack(_ item: ClipItem) { - onAddToPasteStack?(item) - } - /// Places `item`'s recognized OCR text on the clipboard, marked as a self-paste — /// a plain copy, not a paste-in-place, since the user asked to copy the text, not /// paste it. No-ops if the item has no recognized text. @@ -555,17 +523,6 @@ final class ShelfViewModel { if !searchQuery.isEmpty { refresh() } } - func toggleFavorite(_ item: ClipItem) { - guard let id = item.id else { return } - do { - try store.setFavorite(itemID: id, !item.isFavorite) - } catch { - NSLog("Copy: failed to toggle favorite: \(error)") - HUD.show("Couldn't complete that") - } - if !searchQuery.isEmpty { refresh() } - } - // MARK: - Pinboard actions passthrough func createPinboard(name: String, symbol: String, emoji: String? = nil, tint: String = "") { @@ -855,12 +812,7 @@ final class ShelfViewModel { } private func apply(_ new: [ClipItem]) { - // Favorites float to the front (preserving recency within each group); the shelf - // draws a divider at the boundary (`favoritesCount`). - let favorites = new.filter(\.isFavorite) - let rest = new.filter { !$0.isFavorite } - items = favorites + rest - favoritesCount = favorites.count + items = new let order = items.map(\.uuid) selection.prune(existing: Set(order), order: order) if let jump = pendingJumpItemID { diff --git a/Copy/Shelf/TipsSheet.swift b/Copy/Shelf/TipsSheet.swift index 6701947..215d926 100644 --- a/Copy/Shelf/TipsSheet.swift +++ b/Copy/Shelf/TipsSheet.swift @@ -11,10 +11,6 @@ struct TipsSheet: View { KeyboardShortcuts.getShortcut(for: .toggleShelf)?.description ?? "⇧⌘V" } - private var pasteStackHotkey: String { - KeyboardShortcuts.getShortcut(for: .togglePasteStack)?.description ?? "⇧⌘C" - } - var body: some View { VStack(spacing: 0) { HStack { @@ -46,10 +42,6 @@ struct TipsSheet: View { ("⌘Z", "Undo a delete"), ("Drag", "Drop a card on a pinboard tab to keep it"), ]) - section("Paste stack", [ - (pasteStackHotkey, "Toggle the paste stack"), - ("⌘V", "Paste the next queued item, again and again"), - ]) } .padding(16) } diff --git a/Copy/Support/CodeHighlight.swift b/Copy/Support/CodeHighlight.swift index 8f5f1d4..fafe247 100644 --- a/Copy/Support/CodeHighlight.swift +++ b/Copy/Support/CodeHighlight.swift @@ -24,7 +24,7 @@ enum CodeHighlightPalette { /// default (primary) color. Font is intentionally left to the caller's `.font(...)` /// modifier on the returned `Text` — every run here is otherwise plain, so one outer /// `.font()` cascades to the whole concatenation, matching how the plain-text path -/// already applies `Tokens.bodyMono`/mono system font today. +/// already applies a monospaced system font today. func highlightedText(_ text: String, tokens: [HighlightToken]) -> Text { guard !tokens.isEmpty else { return Text(text) } let ns = text as NSString diff --git a/Copy/Support/DesignTokens.swift b/Copy/Support/DesignTokens.swift index 773e5c4..7557f6c 100644 --- a/Copy/Support/DesignTokens.swift +++ b/Copy/Support/DesignTokens.swift @@ -28,9 +28,6 @@ enum Tokens { /// than the platform body style so multi-line cards stay useful without the dense, /// terminal-like texture of the previous monospaced font. static let cardBody = Font.system(size: 12) - /// Retained for explicitly technical compact surfaces such as Paste Stack; regular - /// shelf cards and Space preview use the system face instead. - static let bodyMono = Font.system(size: 11, design: .monospaced) static let caption = Font.system(size: 10, weight: .medium) static let cardTitle = Font.system(size: 11, weight: .semibold) /// Secondary-but-prominent label style: `ItemCardView`'s custom-title row and its diff --git a/Copy/Support/GlassSurface.swift b/Copy/Support/GlassSurface.swift index 10ed0cb..bcd7870 100644 --- a/Copy/Support/GlassSurface.swift +++ b/Copy/Support/GlassSurface.swift @@ -2,7 +2,7 @@ import SwiftUI import AppKit /// Single switch point for adopting Liquid Glass across the app: every M7 surface -/// (shelf, cards, popovers, HUD, paste stack, settings) should route its background +/// (shelf, cards, popovers, HUD, settings) should route its background /// through `glassSurface` rather than reaching for `NSVisualEffectView` directly, so /// the macOS-version gate and the Reduce Transparency fallback only need to be right /// in one place. @@ -28,7 +28,7 @@ import AppKit /// independently; it composes fine with `glassSurface` underneath. extension View { /// All four corners rounded by the same radius — the common case for cards, - /// popovers, the HUD, and the paste stack. + /// popovers and the HUD. func glassSurface(cornerRadius: CGFloat) -> some View { modifier(GlassSurfaceModifier(corners: .all(cornerRadius))) } diff --git a/Copy/Support/IconButton.swift b/Copy/Support/IconButton.swift index a64b024..b0cb157 100644 --- a/Copy/Support/IconButton.swift +++ b/Copy/Support/IconButton.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The single icon-only button used across Copy (shelf header, paste stack, card hover +/// The single icon-only button used across Copy (shelf header, card hover /// actions, etc.). It guarantees a generous, fully-hittable target (a plain `Button` with /// a sized frame + `contentShape` — SwiftUI's `Menu`/borderless styles only made the glyph /// pixels clickable), shows a hover highlight so the target is visible, and carries a diff --git a/Copy/Support/WindowDragHandle.swift b/Copy/Support/WindowDragHandle.swift deleted file mode 100644 index 2b15340..0000000 --- a/Copy/Support/WindowDragHandle.swift +++ /dev/null @@ -1,16 +0,0 @@ -import AppKit -import SwiftUI - -/// A transparent region that drags its window when pressed, placed behind the paste-stack -/// header. The palette is a non-activating panel, so `isMovableByWindowBackground` is off; -/// this drives the drag explicitly via `performDrag`, and accepts the first mouse so it -/// works while Copy is inactive. -struct WindowDragArea: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { DragView() } - func updateNSView(_ nsView: NSView, context: Context) {} - - private final class DragView: NSView { - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } - override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) } - } -} diff --git a/CopyCore/Sources/CopyCore/Models/Constants.swift b/CopyCore/Sources/CopyCore/Models/Constants.swift index cb85fac..b8e51a5 100644 --- a/CopyCore/Sources/CopyCore/Models/Constants.swift +++ b/CopyCore/Sources/CopyCore/Models/Constants.swift @@ -8,10 +8,4 @@ public enum CopyPasteboard { /// Favicon representation UTI for link metadata. public static let faviconUTI = "sk.brzv.copy.favicon" - /// Marks a synthesized ⌘V's CGEvent (`eventSourceUserData`) as Copy's own, so any - /// tap watching for a *user-initiated* ⌘V — e.g. `PasteStackEngine`'s CGEvent tap — - /// lets it straight through instead of intercepting it. Every place Copy posts its - /// own ⌘V (`CGKeyEventPoster.postCommandV()`, `PasteStackEngine.postMarkedPasteKeystroke()`) - /// must set this field so there is exactly one definition of "this is our keystroke." - public static let selfEventUserData: Int64 = 0xC0_50_11 } diff --git a/CopyCore/Sources/CopyCore/Models/Records.swift b/CopyCore/Sources/CopyCore/Models/Records.swift index 2d7c0d1..72dec3d 100644 --- a/CopyCore/Sources/CopyCore/Models/Records.swift +++ b/CopyCore/Sources/CopyCore/Models/Records.swift @@ -15,6 +15,8 @@ public struct ClipItem: Codable, Equatable, Identifiable, FetchableRecord, Mutab public var appName: String? public var contentHash: String public var sizeBytes: Int + /// Legacy storage retained for database and archive compatibility; product behavior + /// no longer reads or mutates it. public var isFavorite: Bool public var title: String? public var recognizedText: String? diff --git a/CopyCore/Sources/CopyCore/Paste/PasteService.swift b/CopyCore/Sources/CopyCore/Paste/PasteService.swift index 5300278..aa74b91 100644 --- a/CopyCore/Sources/CopyCore/Paste/PasteService.swift +++ b/CopyCore/Sources/CopyCore/Paste/PasteService.swift @@ -40,11 +40,6 @@ public struct CGKeyEventPoster: KeyEventPosting { let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) keyDown?.flags = .maskCommand keyUp?.flags = .maskCommand - // Mark as Copy's own synthesized keystroke so the Paste Stack's CGEvent tap - // (which watches for a *user-initiated* plain ⌘V) lets this one straight - // through instead of hijacking it as a "walk the queue" request. - keyDown?.setIntegerValueField(.eventSourceUserData, value: CopyPasteboard.selfEventUserData) - keyUp?.setIntegerValueField(.eventSourceUserData, value: CopyPasteboard.selfEventUserData) keyDown?.post(tap: .cghidEventTap) keyUp?.post(tap: .cghidEventTap) } diff --git a/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift b/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift deleted file mode 100644 index 2103cab..0000000 --- a/CopyCore/Sources/CopyCore/Paste/PasteStackQueue.swift +++ /dev/null @@ -1,49 +0,0 @@ -public struct PasteStackQueue: Equatable, Sendable { - public private(set) var itemUUIDs: [String] = [] - public var isLIFO = false - - public init() {} - - public mutating func enqueue(_ uuid: String) { - if let index = itemUUIDs.firstIndex(of: uuid) { - itemUUIDs.remove(at: index) - } - itemUUIDs.append(uuid) - } - - public mutating func remove(_ uuid: String) { - itemUUIDs.removeAll { $0 == uuid } - } - - public mutating func move(from: Int, to: Int) { - guard from >= 0, from < itemUUIDs.count, - to >= 0, to < itemUUIDs.count else { - return - } - let item = itemUUIDs.remove(at: from) - itemUUIDs.insert(item, at: to) - } - - public mutating func clear() { - itemUUIDs.removeAll() - } - - public var next: String? { - isLIFO ? itemUUIDs.last : itemUUIDs.first - } - - @discardableResult - public mutating func advance() -> String? { - guard !itemUUIDs.isEmpty else { return nil } - let index = isLIFO ? itemUUIDs.count - 1 : 0 - return itemUUIDs.remove(at: index) - } - - public var isEmpty: Bool { - itemUUIDs.isEmpty - } - - public var count: Int { - itemUUIDs.count - } -} diff --git a/CopyCore/Sources/CopyCore/Storage/ArchiveIO.swift b/CopyCore/Sources/CopyCore/Storage/ArchiveIO.swift index cc7d04d..6fd4a2e 100644 --- a/CopyCore/Sources/CopyCore/Storage/ArchiveIO.swift +++ b/CopyCore/Sources/CopyCore/Storage/ArchiveIO.swift @@ -8,8 +8,8 @@ public struct ArchivedRep: Codable, Equatable { } /// A clipboard item as it appears inside a `ClipArchive`. Carries everything needed to -/// reconstruct the item faithfully (timestamps, title, favorite flag, recognized OCR -/// text, and every representation), keyed for dedup by `contentHash` — the same hash +/// reconstruct the item faithfully (timestamps, title, recognized OCR text, and every +/// representation), keyed for dedup by `contentHash` — the same hash /// `ItemStore` already uses to recognize identical content. public struct ArchivedItem: Codable, Equatable { public let kind: String @@ -22,6 +22,7 @@ public struct ArchivedItem: Codable, Equatable { public let createdAt: Date public let lastUsedAt: Date public let contentHash: String + /// Retained only so archives made by older Copy versions remain decodable. public let isFavorite: Bool public let representations: [ArchivedRep] } diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index 5a1460e..568a571 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -101,16 +101,6 @@ public struct ItemStore { } } - /// Looks up a single item by its stable uuid, regardless of how far back it sits - /// in `lastUsedAt` order — unlike `recentItems`, this isn't bounded by a `limit`, - /// so it's the right tool for resolving a uuid held elsewhere (e.g. a Paste Stack - /// queue entry) that may have aged out of any "recent" window. - public func item(uuid: String) throws -> ClipItem? { - try writer.read { db in - try ClipItem.filter(Column("uuid") == uuid).fetchOne(db) - } - } - public func representations(forItemID id: Int64) throws -> [CapturedRepresentation] { let records = try writer.read { db in try Representation.filter(Column("itemId") == id).fetchAll(db) @@ -182,26 +172,19 @@ public struct ItemStore { } /// Faceted search (see `SearchFilter`): FTS-matches `filter.text` when present, then - /// AND-applies the app/kind/date/favorites/pinboard facets. With no text it's a plain + /// AND-applies the app/kind/date/pinboard facets. With no text it's a plain /// `SELECT` (no FTS), so facet-only queries work without a text pattern. - /// Results page the same way history does, so favorites get the same exemption from - /// `limit` for the same reason — see `fetchRecentPage`. Matching favorites come first - /// and in full; everything else is bounded. public func search(filter: SearchFilter, limit: Int = 100) throws -> [ClipItem] { - guard let favorites = searchQuery(filter, isFavorite: true, limit: nil), - let rest = searchQuery(filter, isFavorite: false, limit: limit) else { return [] } + guard let query = searchQuery(filter, limit: limit) else { return [] } return try writer.read { db in - try ClipItem.fetchAll(db, sql: favorites.sql, - arguments: StatementArguments(favorites.arguments)) - + ClipItem.fetchAll(db, sql: rest.sql, - arguments: StatementArguments(rest.arguments)) + try ClipItem.fetchAll(db, sql: query.sql, + arguments: StatementArguments(query.arguments)) } } - /// Builds one half of `search(filter:)`: the matching rows on one side of the favorite - /// split, newest first, optionally bounded. Returns nil when the free text can't compile - /// to an FTS pattern, which the caller treats as "no results". - private func searchQuery(_ filter: SearchFilter, isFavorite: Bool, limit: Int?) + /// Builds `search(filter:)`, newest first and bounded. Returns nil when the free text + /// can't compile to an FTS pattern, which the caller treats as "no results". + private func searchQuery(_ filter: SearchFilter, limit: Int) -> (sql: String, arguments: [any DatabaseValueConvertible])? { var sql: String var arguments: [any DatabaseValueConvertible] = [] @@ -219,13 +202,9 @@ public struct ItemStore { let facets = facetClauses(filter) sql += facets.sql arguments.append(contentsOf: facets.arguments) - // A literal 0/1 from a Bool, not caller text, so there's nothing to bind or escape. - sql += " AND item.isFavorite = \(isFavorite ? 1 : 0)" sql += " ORDER BY item.lastUsedAt DESC" - if let limit { - sql += " LIMIT ?" - arguments.append(limit) - } + sql += " LIMIT ?" + arguments.append(limit) return (sql, arguments) } @@ -247,9 +226,6 @@ public struct ItemStore { arguments.append(range.start) arguments.append(range.end) } - if filter.favoritesOnly { - sql += " AND item.isFavorite = 1" - } if !filter.pinboardIDs.isEmpty { let ids = filter.pinboardIDs.sorted() sql += " AND item.id IN (SELECT itemId FROM pinboard_item WHERE pinboardId IN (\(ids.map { _ in "?" }.joined(separator: ","))))" @@ -286,61 +262,43 @@ public struct ItemStore { } } - public func setFavorite(itemID: Int64, _ favorite: Bool) throws { - try writer.write { db in - try db.execute( - sql: "UPDATE item SET isFavorite = ? WHERE id = ?", - arguments: [favorite, itemID]) - } - } - public func delete(itemID: Int64) throws { try writer.write { db in try deleteItems(ClipItem.filter(Column("id") == itemID), in: db) } } - public func clearHistory(keepFavorites: Bool = true) throws { + public func clearHistory() throws { try writer.write { db in let memberIDs = "SELECT DISTINCT itemId FROM pinboard_item" - var doomed = ClipItem.filter(sql: "id NOT IN (\(memberIDs))") - if keepFavorites { - doomed = doomed.filter(Column("isFavorite") == false) - } + let doomed = ClipItem.filter(sql: "id NOT IN (\(memberIDs))") try deleteItems(doomed, in: db) } } - /// Clears just one kind from the clearable history — same keep rules as - /// `clearHistory(keepFavorites:)` (pinboard members and, when `keepFavorites`, - /// favorites are preserved). Used by the Settings storage view's per-type "Clear". - public func clearHistory(kind: ItemKind, keepFavorites: Bool = true) throws { + /// Clears just one kind from the clearable history, preserving pinboard members. + /// Used by the Settings storage view's per-type "Clear". + public func clearHistory(kind: ItemKind) throws { try writer.write { db in let memberIDs = "SELECT DISTINCT itemId FROM pinboard_item" - var doomed = ClipItem + let doomed = ClipItem .filter(sql: "id NOT IN (\(memberIDs))") .filter(Column("kind") == kind.rawValue) - if keepFavorites { - doomed = doomed.filter(Column("isFavorite") == false) - } try deleteItems(doomed, in: db) } } - /// Per-kind item count and total `sizeBytes` over the *clearable* history: items not in - /// any pinboard and (when `keepFavorites`) not favorited — exactly the set - /// `clearHistory(keepFavorites:)` removes. Favorites and pinboard items are permanent - /// (excluded from the retention prune too), so they aren't counted here. Kinds with no - /// clearable items are omitted. Powers the Settings storage breakdown. - public func storageBreakdown(keepFavorites: Bool = true) throws -> [StorageUsage] { + /// Per-kind item count and total `sizeBytes` over the clearable history: items not in + /// any pinboard, exactly the set `clearHistory()` removes. Kinds with no clearable + /// items are omitted. Powers the Settings storage breakdown. + public func storageBreakdown() throws -> [StorageUsage] { try writer.read { db in - var sql = """ + let sql = """ SELECT kind AS k, COUNT(*) AS c, COALESCE(SUM(sizeBytes), 0) AS b FROM item WHERE id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) + GROUP BY kind """ - if keepFavorites { sql += " AND isFavorite = 0" } - sql += " GROUP BY kind" return try Row.fetchAll(db, sql: sql).compactMap { row in guard let raw: String = row["k"], let kind = ItemKind(rawValue: raw) else { return nil } let count: Int = row["c"] @@ -395,9 +353,6 @@ public struct ItemStore { if let range = filter.dateRange { request = request.filter(Column("lastUsedAt") >= range.start && Column("lastUsedAt") < range.end) } - if filter.favoritesOnly { - request = request.filter(Column("isFavorite") == true) - } if !filter.pinboardIDs.isEmpty { let ids = filter.pinboardIDs.sorted() let placeholders = ids.map { _ in "?" }.joined(separator: ",") @@ -428,28 +383,12 @@ public struct ItemStore { return ("(\(clauses.joined(separator: " OR ")))", arguments) } - /// One page of shelf history: every favorite matching `filter`, plus the `limit` most - /// recent non-favorites, favorites first. - /// - /// Favorites are deliberately exempt from `limit`. The shelf floats them to the front of - /// its row, so bounding them by the same recency window would hide a favorite as soon as - /// `limit` newer items existed, then drop it into the first position once scrolling grew - /// the window — shifting every visible card sideways mid-scroll. Favorites are a small - /// curated set that retention never deletes (see `RetentionPeriod`), so fetching all of - /// them keeps the front of the row fixed for one extra indexed read. - /// - /// The two queries can't overlap: one asks for `isFavorite = true` and the other for - /// `isFavorite = false`, so concatenating them never duplicates a row. + /// One page of shelf history matching `filter`, newest first. func fetchRecentPage(_ db: Database, filter: SearchFilter, limit: Int) throws -> [ClipItem] { - let base = applyFacets(filter, to: ClipItem.all()) - let favorites = try base.filter(Column("isFavorite") == true) - .order(Column("lastUsedAt").desc) - .fetchAll(db) - let rest = try base.filter(Column("isFavorite") == false) + try applyFacets(filter, to: ClipItem.all()) .order(Column("lastUsedAt").desc) .limit(limit) .fetchAll(db) - return favorites + rest } /// Synchronous counterpart to `observeRecent(filter:limit:)`, for callers that want one @@ -616,7 +555,7 @@ public struct ItemStore { if let cutoff { let oldIds = try Int64.fetchAll(db, sql: """ SELECT id FROM item - WHERE lastUsedAt < ? AND isFavorite = false + WHERE lastUsedAt < ? AND id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) """, arguments: [cutoff]) doomed.formUnion(oldIds) @@ -625,8 +564,7 @@ public struct ItemStore { if let maxItems { let newestIds = try Int64.fetchAll(db, sql: """ SELECT id FROM item - WHERE isFavorite = false - AND id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) + WHERE id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) ORDER BY lastUsedAt DESC LIMIT ? """, arguments: [maxItems]) @@ -634,8 +572,7 @@ public struct ItemStore { let excessIds = try Int64.fetchAll(db, sql: """ SELECT id FROM item - WHERE isFavorite = false - AND id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) + WHERE id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) """) for id in excessIds { if !newestSet.contains(id) { @@ -766,7 +703,7 @@ public struct ItemStore { } /// Inserts an item reconstructed from an exported archive (`ArchiveIO`), preserving - /// its original timestamps, title, favorite flag, and recognized text so a restored + /// its original timestamps, title, legacy favorite flag, and recognized text so a restored /// history is indistinguishable from the original. Dedups by content hash like /// `save`/`createTextItem`: if an item with the same hash already exists, this is a /// no-op and returns `false` — the property that makes re-importing the same diff --git a/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift b/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift index 7015a61..dabf6c9 100644 --- a/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift +++ b/CopyCore/Sources/CopyCore/Storage/SearchFilter.swift @@ -15,7 +15,6 @@ public struct SearchFilter: Equatable, Sendable { /// Matched against `lastUsedAt` — consistent with ordering, retention, and the relative /// timestamps shown on cards. Half-open `[start, end)`. public var dateRange: DateInterval? - public var favoritesOnly: Bool public var pinboardIDs: Set public init(text: String = "", @@ -23,14 +22,12 @@ public struct SearchFilter: Equatable, Sendable { kinds: Set = [], includesImageFiles: Bool = false, dateRange: DateInterval? = nil, - favoritesOnly: Bool = false, pinboardIDs: Set = []) { self.text = text self.appBundleID = appBundleID self.kinds = kinds self.includesImageFiles = includesImageFiles self.dateRange = dateRange - self.favoritesOnly = favoritesOnly self.pinboardIDs = pinboardIDs } @@ -41,7 +38,6 @@ public struct SearchFilter: Equatable, Sendable { && kinds.isEmpty && !includesImageFiles && dateRange == nil - && !favoritesOnly && pinboardIDs.isEmpty } diff --git a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift index d787e07..2467706 100644 --- a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift +++ b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift @@ -76,7 +76,6 @@ public enum SearchToken: Equatable, Identifiable, Sendable { case app(bundleID: String, name: String) case type(SearchType) case date(SearchDate) - case favorites case pinboard(id: Int64, name: String) public var id: String { @@ -84,7 +83,6 @@ public enum SearchToken: Equatable, Identifiable, Sendable { case .app(let bundleID, _): return "app:\(bundleID)" case .type(let type): return "type:\(type.rawValue)" case .date(let date): return "date:\(date.rawValue)" - case .favorites: return "favorites" case .pinboard(let id, _): return "pinboard:\(id)" } } @@ -94,7 +92,6 @@ public enum SearchToken: Equatable, Identifiable, Sendable { case .app(_, let name): return name case .type(let type): return type.label case .date(let date): return date.label - case .favorites: return "Favorites" case .pinboard(_, let name): return name } } @@ -105,7 +102,6 @@ public enum SearchToken: Equatable, Identifiable, Sendable { case .app: return nil case .type(let type): return type.systemImage case .date(let date): return date.systemImage - case .favorites: return "star.fill" case .pinboard: return "pin.fill" } } @@ -142,7 +138,6 @@ public struct SearchQuery: Equatable, Sendable { kinds.formUnion(type.kinds) if type == .images { filter.includesImageFiles = true } case .date(let date): filter.dateRange = date.interval(now: now, calendar: calendar) - case .favorites: filter.favoritesOnly = true case .pinboard(let id, _): pinboardIDs.insert(id) } } @@ -180,7 +175,6 @@ public enum Suggestion: Equatable, Identifiable, Sendable { case app(bundleID: String, name: String) case type(SearchType) case date(SearchDate) - case favorites case pinboard(id: Int64, name: String) public var token: SearchToken { @@ -188,7 +182,6 @@ public enum Suggestion: Equatable, Identifiable, Sendable { case .app(let bundleID, let name): return .app(bundleID: bundleID, name: name) case .type(let type): return .type(type) case .date(let date): return .date(date) - case .favorites: return .favorites case .pinboard(let id, let name): return .pinboard(id: id, name: name) } } @@ -198,20 +191,19 @@ public enum Suggestion: Equatable, Identifiable, Sendable { public var systemImage: String? { token.systemImage } public var appBundleID: String? { token.appBundleID } - /// Category order in the dropdown: type, favorites, pinboard, app, date. + /// Category order in the dropdown: type, pinboard, app, date. var priority: Int { switch self { case .type: return 0 - case .favorites: return 1 - case .pinboard: return 2 - case .app: return 3 - case .date: return 4 + case .pinboard: return 1 + case .app: return 2 + case .date: return 3 } } } /// Ranked facet suggestions for the current trailing text. Case-insensitive prefix match -/// across type/favorites/pinboard/app/date, excluding facets already satisfied by `query` +/// across type/pinboard/app/date, excluding facets already satisfied by `query` /// (single-valued app/date once set; any already-present token). Empty prefix → no /// suggestions. Stable-sorted by category priority, then by input order (apps stay in the /// frequency order `distinctApps` returns). @@ -225,16 +217,11 @@ public func searchSuggestions(prefix rawPrefix: String, let hasApp = query.tokens.contains { if case .app = $0 { return true } else { return false } } let hasDate = query.tokens.contains { if case .date = $0 { return true } else { return false } } - let hasFavorites = query.tokens.contains(.favorites) - var out: [Suggestion] = [] for type in SearchType.allCases where matchesPrefix(type.label, prefix) && !query.tokens.contains(.type(type)) { out.append(.type(type)) } - if matchesPrefix("Favorites", prefix) && !hasFavorites { - out.append(.favorites) - } for pinboard in pinboards { guard let id = pinboard.id, matchesPrefix(pinboard.name, prefix), !query.tokens.contains(.pinboard(id: id, name: pinboard.name)) else { continue } diff --git a/CopyCore/Tests/CopyCoreTests/ArchiveIOTests.swift b/CopyCore/Tests/CopyCoreTests/ArchiveIOTests.swift index a929d1d..cb0d62b 100644 --- a/CopyCore/Tests/CopyCoreTests/ArchiveIOTests.swift +++ b/CopyCore/Tests/CopyCoreTests/ArchiveIOTests.swift @@ -22,8 +22,7 @@ final class ArchiveIOTests: XCTestCase { let (items, pinboards) = try makeTempStores() let plain = try items.save(makeText("plain body")) - let favorite = try items.save(makeText("favorite body")) - try items.setFavorite(itemID: favorite.id!, true) + let secondPlain = try items.save(makeText("second plain body")) let titled = try items.createTextItem("titled body", title: "My Title") // A representation larger than ItemStore.inlineThreshold forces the blob-file // storage path, so this also proves blob-backed representations round-trip. @@ -50,9 +49,8 @@ final class ArchiveIOTests: XCTestCase { let recent = try freshItems.recentItems(limit: 10) XCTAssertEqual(recent.count, 4) - let restoredFavorite = recent.first { $0.contentHash == favorite.contentHash } - XCTAssertEqual(restoredFavorite?.isFavorite, true) - XCTAssertEqual(restoredFavorite?.plainText, "favorite body") + let restoredSecondPlain = recent.first { $0.contentHash == secondPlain.contentHash } + XCTAssertEqual(restoredSecondPlain?.plainText, "second plain body") let restoredTitled = recent.first { $0.contentHash == titled.contentHash } XCTAssertEqual(restoredTitled?.title, "My Title") diff --git a/CopyCore/Tests/CopyCoreTests/CarryoverTests.swift b/CopyCore/Tests/CopyCoreTests/CarryoverTests.swift index b69091a..db5596e 100644 --- a/CopyCore/Tests/CopyCoreTests/CarryoverTests.swift +++ b/CopyCore/Tests/CopyCoreTests/CarryoverTests.swift @@ -16,7 +16,7 @@ final class CarryoverTests: XCTestCase { let store = ItemStore(writer: dbm.writer, blobs: BlobStore(directory: dbm.blobsDirectory)) _ = try store.save(makeBlobItem(0x01, hashSeed: "a")) _ = try store.save(makeBlobItem(0x02, hashSeed: "b")) - try store.clearHistory(keepFavorites: true) + try store.clearHistory() XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dbm.blobsDirectory.path).count, 0) XCTAssertEqual(try store.recentItems(limit: 10).count, 0) } @@ -34,14 +34,6 @@ final class CarryoverTests: XCTestCase { "blob still referenced by the second item must survive") } - func testClearHistoryIncludingFavorites() throws { - let store = try makeTempStore() - let fav = try store.save(makeText("fav")) - try store.setFavorite(itemID: fav.id!, true) - try store.clearHistory(keepFavorites: false) - XCTAssertEqual(try store.recentItems(limit: 10).count, 0) - } - func testSearchStillFindsItemAfterTouch() throws { let store = try makeTempStore() let item = try store.save(makeText("touchable content")) diff --git a/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift b/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift deleted file mode 100644 index 44d2fc5..0000000 --- a/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import XCTest -@testable import CopyCore - -/// The shelf floats favorites to the front of its row. If the history query bounded -/// favorites by the same recency window as everything else, an old favorite would be -/// invisible until paging happened to reach it, then appear at position 0 and shift -/// every card sideways under the user's cursor mid-scroll. These pin down that -/// favorites are exempt from the page limit, so the front of the row never moves as -/// pages load. -final class FavoritesPagingTests: XCTestCase { - /// Saves `count` items oldest first, one second apart, so `lastUsedAt` ordering is - /// deterministic rather than dependent on how fast the test machine runs. - @discardableResult - private func seed(_ store: ItemStore, count: Int, from start: Date) throws -> [ClipItem] { - try (0..⏎ pastes the selected card; ⌥⏎ strips formatting and pastes plain text - Two-click-to-paste by default (single click selects, double click pastes), or switch to single-click -- Paste stack: queue several cards and walk through them on successive ⌘V presses, - shown as a numbered queue with the next item marked - ⌘O opens the selected link or file without pasting; ⌘Z undoes a delete - App Intents so Shortcuts can paste your latest item, search, or paste from a pinboard @@ -102,7 +100,6 @@ and **Import…** in this one. | ⌫ | Delete selected card | | ⌘⌫ | Delete selected card | | ⌘N | New item | -| ⇧⌘C | Toggle the paste stack | ## Build from source