diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index cadf9fb..bc62da0 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -421,6 +421,9 @@ final class AppCoordinator { self?.shelfController.setHideDuringScreenSharing(hide) self?.pasteStackController.setHideDuringScreenSharing(hide) } + settings.onFavoritesEnabledChange = { [weak self] _ in + self?.shelfViewModel.favoritesSettingChanged() + } settings.onCompactShelfChange = { [weak self] compact in self?.shelfController.setCompactShelf(compact) } diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 56d1784..32215db 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -33,6 +33,14 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) } + Section { + Toggle("Enable Favorites", isOn: $settings.favoritesEnabled) + } footer: { + Text("Show favorite actions and keep favorites at the front of history. Turning this off preserves saved favorites and their protection from automatic cleanup.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { Toggle("Always Use Dark Shelf", isOn: $settings.shelfProDark) } footer: { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 40a3983..dc73b2c 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -80,6 +80,7 @@ final class SettingsStore { static let recognizeImageTextKey = "recognizeImageText" static let excludedBundleIDsKey = "excludedBundleIDs" static let hideDuringScreenSharingKey = "hideDuringScreenSharing" + static let favoritesEnabledKey = "favoritesEnabled" static let compactShelfKey = "compactShelf" static let shelfProDarkKey = "shelfProDark" static let hideMenuBarIconKey = "hideMenuBarIcon" @@ -141,6 +142,16 @@ final class SettingsStore { } } + /// Hides favorites presentation without deleting saved marks or retention protection. + var favoritesEnabled: Bool { + didSet { + guard favoritesEnabled != oldValue else { return } + defaults.set(favoritesEnabled, forKey: Self.favoritesEnabledKey) + onFavoritesEnabledChange?(favoritesEnabled) + } + } + @ObservationIgnored var onFavoritesEnabledChange: ((Bool) -> Void)? + /// A fixed "pro dark" look for the shelf and paste stack: 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 @@ -224,6 +235,7 @@ final class SettingsStore { fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false + favoritesEnabled = (defaults.object(forKey: Self.favoritesEnabledKey) as? Bool) ?? true compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false shelfProDark = (defaults.object(forKey: Self.shelfProDarkKey) as? Bool) ?? false hideMenuBarIcon = (defaults.object(forKey: Self.hideMenuBarIconKey) as? Bool) ?? false diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index 086c143..f423674 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -13,6 +13,7 @@ struct ItemCardView: View { let currentPinboardID: Int64? /// Compact shelf mode (`SettingsStore.compactShelf`, threaded from `ShelfRootView`): /// smaller card frame + tighter line limits so more cards fit on screen at once. + var favoritesEnabled: Bool = true var compact: Bool = false /// The active search text, used to show and highlight the matched OCR snippet under /// an image result. Empty when not searching. @@ -163,7 +164,7 @@ struct ItemCardView: View { // floating pill so they're discoverable without opening the context menu. // Otherwise, just the quiet favorite indicator when the card is favorited. if isHovering && !isInlineRenaming { - CardHoverActions(isFavorite: item.isFavorite, + CardHoverActions(favoritesEnabled: favoritesEnabled, isFavorite: item.isFavorite, onToggleFavorite: onToggleFavorite, // Only offer unpin while viewing a pinboard, where the card // actually belongs to one it can be removed from. @@ -171,7 +172,7 @@ struct ItemCardView: View { onDelete: onDelete) .padding(5) .transition(.opacity) - } else if item.isFavorite { + } else if favoritesEnabled && item.isFavorite { Image(systemName: "star.fill") .font(.system(size: 9)) .foregroundStyle(.yellow) @@ -227,7 +228,9 @@ struct ItemCardView: View { Button("Adjust Color…", action: onAdjustColor) } Button("Rename…", action: onBeginInlineRename) - Button(item.isFavorite ? "Unfavorite" : "Favorite", action: onToggleFavorite) + if favoritesEnabled { + Button(item.isFavorite ? "Unfavorite" : "Favorite", action: onToggleFavorite) + } Menu("Add to Pinboard") { if pinboards.isEmpty { Text("No Pinboards") @@ -657,6 +660,7 @@ private struct CardClickGesture: ViewModifier { /// in the right-click menu, 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 favoritesEnabled: Bool let isFavorite: Bool let onToggleFavorite: () -> Void /// Removes the card from the pinboard currently being viewed. `nil` (and so hidden) @@ -666,11 +670,13 @@ private struct CardHoverActions: View { var body: some View { HStack(spacing: 1) { + if favoritesEnabled { 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/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 7e5889d..93a2ac6 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -499,6 +499,7 @@ private struct ShelfItemsRow: View { store: viewModel.store, pinboards: viewModel.pinboards, currentPinboardID: currentPinboardID, + favoritesEnabled: viewModel.settings.favoritesEnabled, compact: compact, searchQuery: viewModel.searchQuery.text, isFlashing: viewModel.flashItemID == item.uuid, diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 625b7c7..26641b4 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -185,6 +185,12 @@ final class ShelfViewModel { /// Restarts the query after the search, tab, or facets changed. A new query starts back /// at the first page, so the window never carries over from the previous one. + func favoritesSettingChanged() { + if !settings.favoritesEnabled { searchQuery.remove(.favorites) } + recomputeSuggestions() + refresh() + } + func refresh() { page.reset() previewShown = false @@ -211,7 +217,7 @@ final class ShelfViewModel { if filter.hasText { // Text search is one-shot (FTS); re-runs whenever the query changes. isPaged = true - apply((try? store.search(filter: filter, limit: page.limit)) ?? []) + apply((try? store.search(filter: filter, limit: page.limit, favoritesFirst: settings.favoritesEnabled)) ?? []) } else if searchQuery.tokens.isEmpty, case .pinboard(let id) = tab { // Plain pinboard browse: keep the dedicated observation (preserves manual order). isPaged = false @@ -221,7 +227,7 @@ final class ShelfViewModel { } else { // History browse or facet-only: stays live as new items are captured. isPaged = true - token = store.observeRecent(filter: filter, limit: page.limit, + token = store.observeRecent(filter: filter, limit: page.limit, favoritesFirst: settings.favoritesEnabled, onError: { NSLog("Copy: observation failed: \($0)") }, onChange: { [weak self] in self?.apply($0) }) } @@ -260,6 +266,7 @@ final class ShelfViewModel { /// Commits a suggestion as a pill, clearing the typed prefix. func acceptSuggestion(_ suggestion: Suggestion) { + guard settings.favoritesEnabled || suggestion.token != .favorites else { return } searchQuery.add(suggestion.token) searchQuery.text = "" suggestions = [] @@ -330,7 +337,8 @@ final class ShelfViewModel { cachedApps = (try? store.distinctApps()) ?? [] } suggestions = searchSuggestions(prefix: searchQuery.text, apps: cachedApps, - pinboards: pinboards, query: searchQuery) + pinboards: pinboards, query: searchQuery, + includeFavorites: settings.favoritesEnabled) highlightedSuggestion = 0 } @@ -523,6 +531,7 @@ final class ShelfViewModel { } func toggleFavoritePrimary() { + guard settings.favoritesEnabled else { return } guard let item = primaryItem, let id = item.id else { return } do { try store.setFavorite(itemID: id, !item.isFavorite) @@ -580,6 +589,7 @@ final class ShelfViewModel { } func toggleFavorite(_ item: ClipItem) { + guard settings.favoritesEnabled else { return } guard let id = item.id else { return } do { try store.setFavorite(itemID: id, !item.isFavorite) @@ -883,8 +893,8 @@ final class ShelfViewModel { // 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 = settings.favoritesEnabled ? favorites + rest : new + favoritesCount = settings.favoritesEnabled ? favorites.count : 0 let order = items.map(\.uuid) selection.prune(existing: Set(order), order: order) if let jump = pendingJumpItemID { diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index 197045b..26fd228 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -187,7 +187,14 @@ public struct ItemStore { /// 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] { + /// Set `favoritesFirst` to false for one bounded recency window, without changing marks. + public func search(filter: SearchFilter, limit: Int = 100, favoritesFirst: Bool = true) throws -> [ClipItem] { + if !favoritesFirst { + guard let query = searchQuery(filter, isFavorite: nil, limit: limit) else { return [] } + return try writer.read { db in + try ClipItem.fetchAll(db, sql: query.sql, arguments: StatementArguments(query.arguments)) + } + } guard let favorites = searchQuery(filter, isFavorite: true, limit: nil), let rest = searchQuery(filter, isFavorite: false, limit: limit) else { return [] } return try writer.read { db in @@ -201,7 +208,7 @@ public struct ItemStore { /// 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?) + private func searchQuery(_ filter: SearchFilter, isFavorite: Bool?, limit: Int?) -> (sql: String, arguments: [any DatabaseValueConvertible])? { var sql: String var arguments: [any DatabaseValueConvertible] = [] @@ -220,7 +227,7 @@ public struct ItemStore { 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)" + if let isFavorite { sql += " AND item.isFavorite = \(isFavorite ? 1 : 0)" } sql += " ORDER BY item.lastUsedAt DESC" if let limit { sql += " LIMIT ?" @@ -440,8 +447,11 @@ public struct ItemStore { /// /// The two queries can't overlap: one asks for `isFavorite = true` and the other for /// `isFavorite = false`, so concatenating them never duplicates a row. - func fetchRecentPage(_ db: Database, filter: SearchFilter, limit: Int) throws -> [ClipItem] { + func fetchRecentPage(_ db: Database, filter: SearchFilter, limit: Int, favoritesFirst: Bool = true) throws -> [ClipItem] { let base = applyFacets(filter, to: ClipItem.all()) + if !favoritesFirst { + return try base.order(Column("lastUsedAt").desc).limit(limit).fetchAll(db) + } let favorites = try base.filter(Column("isFavorite") == true) .order(Column("lastUsedAt").desc) .fetchAll(db) @@ -454,8 +464,9 @@ public struct ItemStore { /// Synchronous counterpart to `observeRecent(filter:limit:)`, for callers that want one /// page rather than a live feed. Same rows, same order. - public func recentPage(filter: SearchFilter, limit: Int = 100) throws -> [ClipItem] { - try writer.read { db in try fetchRecentPage(db, filter: filter, limit: limit) } + /// With `favoritesFirst: false`, favorites count toward the normal recency limit. + public func recentPage(filter: SearchFilter, limit: Int = 100, favoritesFirst: Bool = true) throws -> [ClipItem] { + try writer.read { db in try fetchRecentPage(db, filter: filter, limit: limit, favoritesFirst: favoritesFirst) } } public func observeRecent(kinds: Set? = nil, limit: Int = 100, @@ -479,14 +490,14 @@ public struct ItemStore { /// with the filter's non-text facets applied, so filter-only shelf views keep updating /// as new items are captured. Free text (`filter.text`) is ignored here — the app layer /// routes text queries to the one-shot `search(filter:)` instead. - public func observeRecent(filter: SearchFilter, limit: Int = 100, + public func observeRecent(filter: SearchFilter, limit: Int = 100, favoritesFirst: Bool = true, onError: @escaping (Error) -> Void, onChange: @escaping ([ClipItem]) -> Void) -> ObservationToken { // Built with the query interface (not `facetClauses`' raw SQL) so the observation's // @Sendable fetch closure captures only the Sendable `filter`/`limit`, not the // existential-typed argument array. let observation = ValueObservation.tracking { db -> [ClipItem] in - try fetchRecentPage(db, filter: filter, limit: limit) + try fetchRecentPage(db, filter: filter, limit: limit, favoritesFirst: favoritesFirst) } let cancellable = observation.start(in: writer, scheduling: .async(onQueue: .main), diff --git a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift index d787e07..2ebf3a3 100644 --- a/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift +++ b/CopyCore/Sources/CopyCore/Storage/SmartSearch.swift @@ -219,7 +219,7 @@ public func searchSuggestions(prefix rawPrefix: String, apps: [AppUsage], pinboards: [Pinboard], query: SearchQuery, - limit: Int = 6) -> [Suggestion] { + limit: Int = 6, includeFavorites: Bool = true) -> [Suggestion] { let prefix = rawPrefix.trimmingCharacters(in: .whitespaces).lowercased() guard !prefix.isEmpty else { return [] } @@ -232,7 +232,7 @@ public func searchSuggestions(prefix rawPrefix: String, for type in SearchType.allCases where matchesPrefix(type.label, prefix) && !query.tokens.contains(.type(type)) { out.append(.type(type)) } - if matchesPrefix("Favorites", prefix) && !hasFavorites { + if includeFavorites && matchesPrefix("Favorites", prefix) && !hasFavorites { out.append(.favorites) } for pinboard in pinboards { diff --git a/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift b/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift index 44d2fc5..7aefb4b 100644 --- a/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift +++ b/CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift @@ -126,4 +126,53 @@ final class FavoritesPagingTests: XCTestCase { let store = try makeTempStore() XCTAssertEqual(try store.recentPage(filter: SearchFilter(), limit: 100).count, 0) } + func testDisabledFavoritesUseOneRecencyWindowAndKeepSavedMarks() throws { + let store = try makeTempStore() + let items = try seed(store, count: 150, from: base) + try store.setFavorite(itemID: XCTUnwrap(items[0].id), true) + try store.setFavorite(itemID: XCTUnwrap(items[149].id), true) + let page = try store.recentPage(filter: SearchFilter(), limit: 100, favoritesFirst: false) + XCTAssertEqual(page.map(\.uuid), Array(items.suffix(100).reversed()).map(\.uuid)) + let grown = try store.recentPage(filter: SearchFilter(), limit: 150, favoritesFirst: false) + XCTAssertEqual(Array(grown.prefix(100)).map(\.uuid), page.map(\.uuid)) + XCTAssertEqual(grown.last?.uuid, items[0].uuid) + XCTAssertTrue(try XCTUnwrap(store.item(uuid: items[0].uuid)).isFavorite) + let enabled = try store.recentPage(filter: SearchFilter(), limit: 100) + XCTAssertEqual(Set(enabled.prefix(2).map(\.uuid)), Set([items[0].uuid, items[149].uuid])) + _ = try store.prune(olderThan: base.addingTimeInterval(200), maxItems: nil) + XCTAssertNotNil(try store.item(uuid: items[0].uuid)) + } + + func testDisabledFavoritesTextSearchStillFiltersAndPages() throws { + let store = try makeTempStore() + let items = try seed(store, count: 150, from: base) + try store.setFavorite(itemID: XCTUnwrap(items[0].id), true) + let filter = SearchFilter(text: "item") + let result = try store.search(filter: filter, limit: 100, favoritesFirst: false) + XCTAssertEqual(result.map(\.uuid), Array(items.suffix(100).reversed()).map(\.uuid)) + XCTAssertEqual(try store.search(filter: SearchFilter(text: "missing"), favoritesFirst: false).count, 0) + } + + func testDisabledFavoritesObservationUsesTheSameWindow() throws { + let store = try makeTempStore() + let items = try seed(store, count: 5, from: base) + try store.setFavorite(itemID: XCTUnwrap(items[0].id), true) + let received = expectation(description: "recency observation") + let token = store.observeRecent(filter: SearchFilter(), limit: 2, favoritesFirst: false, + onError: { error in XCTFail("\(error)"); received.fulfill() }, + onChange: { page in + XCTAssertEqual(page.map(\.uuid), [items[4].uuid, items[3].uuid]) + received.fulfill() + }) + wait(for: [received], timeout: 3) + token.cancel() + } + + func testFavoritesSuggestionCanBeHidden() { + let normal = searchSuggestions(prefix: "fav", apps: [], pinboards: [], query: SearchQuery()) + XCTAssertTrue(normal.contains { $0.token == .favorites }) + let hidden = searchSuggestions(prefix: "fav", apps: [], pinboards: [], query: SearchQuery(), includeFavorites: false) + XCTAssertFalse(hidden.contains { $0.token == .favorites }) + } + } diff --git a/Scripts/Tests/FavoritesSettingsTests.swift b/Scripts/Tests/FavoritesSettingsTests.swift new file mode 100644 index 0000000..e5d491e --- /dev/null +++ b/Scripts/Tests/FavoritesSettingsTests.swift @@ -0,0 +1,22 @@ +import Foundation + +@main +struct FavoritesSettingsTests { + @MainActor static func main() { + let name = "CopyTests.Favorites.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defer { defaults.removePersistentDomain(forName: name) } + let settings = SettingsStore(defaults: defaults) + precondition(settings.favoritesEnabled) + var events: [Bool] = [] + settings.onFavoritesEnabledChange = { events.append($0) } + settings.favoritesEnabled = false + settings.favoritesEnabled = false + precondition(events == [false]) + precondition(!SettingsStore(defaults: defaults).favoritesEnabled) + settings.favoritesEnabled = true + precondition(events == [false, true]) + precondition(SettingsStore(defaults: defaults).favoritesEnabled) + print("PASS: favorites default, persistence and live callback") + } +}