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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions Copy/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 12 additions & 0 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions Copy/Shelf/ItemCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -163,15 +164,15 @@ 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.
onUnpin: currentPinboardID != nil ? onRemoveFromPinboard : nil,
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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 15 additions & 5 deletions Copy/Shelf/ShelfViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) })
}
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 19 additions & 8 deletions CopyCore/Sources/CopyCore/Storage/ItemStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = []
Expand All @@ -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 ?"
Expand Down Expand Up @@ -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)
Expand All @@ -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<ItemKind>? = nil, limit: Int = 100,
Expand All @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions CopyCore/Sources/CopyCore/Storage/SmartSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [] }

Expand All @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions CopyCore/Tests/CopyCoreTests/FavoritesPagingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

}
Loading