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
1 change: 1 addition & 0 deletions Clipy/Sources/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ struct Constants {
static let pasteCounts = "kCPYPasteCounts"
static let boardManUsePanelUI = "BoardManUsePanelUI"
static let boardManShowRowNumbers = "BoardManShowRowNumbers"
static let boardManHistoryUsageFilter = "BoardManHistoryUsageFilter"
static let boardManTimestampFormat = "BoardManTimestampFormat"
static let boardManPanelHeight = "BoardManPanelHeight"
static let boardManShowUsageCount = "BoardManShowUsageCount"
Expand Down
15 changes: 14 additions & 1 deletion Clipy/Sources/Extensions/Realm+Migration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ extension Realm {
}
}
}
if oldSchemaVersion < 8 {
migration.enumerateObjects(ofType: CPYClip.className()) { oldObject, newObject in
let updateTime = oldObject?["updateTime"] as? Int ?? 0
var createdTime = updateTime * 1000
if let dataPath = oldObject?["dataPath"] as? String,
!dataPath.isEmpty,
let attributes = try? FileManager.default.attributesOfItem(atPath: dataPath),
let fileDate = (attributes[.creationDate] as? Date) ?? (attributes[.modificationDate] as? Date) {
createdTime = Int(fileDate.timeIntervalSince1970 * 1000)
}
newObject?["createdTime"] = createdTime
}
}
})
Realm.Configuration.defaultConfiguration = config

Expand All @@ -73,7 +86,7 @@ enum LegacySnippetMigrationResult: Equatable {
}

enum LegacySnippetMigrationService {
static let schemaVersion: UInt64 = 7
static let schemaVersion: UInt64 = 8

private struct SnippetSnapshot {
let index: Int
Expand Down
165 changes: 156 additions & 9 deletions Clipy/Sources/Managers/MenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,11 @@ extension MenuManager {

fileprivate func boardManHistoryItems() -> [BoardManHistoryItem] {
let startedAt = CFAbsoluteTimeGetCurrent()
let maxHistory = max(1, AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize))
let ascending = !AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting)
let clipResults = realm.objects(CPYClip.self).sorted(byKeyPath: #keyPath(CPYClip.updateTime), ascending: ascending)
let defaults = AppEnvironment.current.defaults
let maxHistory = max(1, defaults.integer(forKey: Constants.UserDefaults.maxHistorySize))
let usesRecentOrder = defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting)
let sortKeyPath = usesRecentOrder ? #keyPath(CPYClip.updateTime) : #keyPath(CPYClip.createdTime)
let clipResults = realm.objects(CPYClip.self).sorted(byKeyPath: sortKeyPath, ascending: false)
let showRowNumbers = defaults.object(forKey: Constants.UserDefaults.boardManShowRowNumbers) as? Bool ?? true
let timestampFormat = BoardManPanel.allowedTimestampFormat(defaults.string(forKey: Constants.UserDefaults.boardManTimestampFormat))
let showUsageCount = defaults.object(forKey: Constants.UserDefaults.boardManShowUsageCount) as? Bool ?? true
Expand Down Expand Up @@ -564,8 +565,9 @@ private extension MenuManager {
var subMenuCount = placeInLine
var subMenuIndex = 1 + placeInLine

let ascending = !AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting)
let clipResults = realm.objects(CPYClip.self).sorted(byKeyPath: #keyPath(CPYClip.updateTime), ascending: ascending)
let usesRecentOrder = AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting)
let sortKeyPath = usesRecentOrder ? #keyPath(CPYClip.updateTime) : #keyPath(CPYClip.createdTime)
let clipResults = realm.objects(CPYClip.self).sorted(byKeyPath: sortKeyPath, ascending: false)
let currentSize = Int(clipResults.count)
var i = 0
for clip in clipResults {
Expand Down Expand Up @@ -1063,6 +1065,40 @@ fileprivate enum BoardManUIStyle: String, CaseIterable {
}
}

fileprivate enum BoardManHistoryUsageFilter: String, CaseIterable {
case all = "All"
case unused = "Unused"
case used = "Used"

static func allowed(_ value: String?) -> BoardManHistoryUsageFilter {
return allCases.first(where: { $0.rawValue == value }) ?? .all
}

var symbolName: String {
switch self {
case .all: return "square.stack.3d.up"
case .unused: return "circle"
case .used: return "checkmark.circle.fill"
}
}

var toolTip: String {
switch self {
case .all: return "All — show every clipboard history item (default)."
case .unused: return "Unused — show items that have not been pasted yet."
case .used: return "Used — show items pasted at least once."
}
}

func includes(_ item: BoardManHistoryItem) -> Bool {
switch self {
case .all: return true
case .unused: return item.pasteCount == 0
case .used: return item.pasteCount > 0
}
}
}

fileprivate enum BoardManFontChoice: String, CaseIterable {
case system = "System"
case rounded = "Rounded"
Expand Down Expand Up @@ -1923,6 +1959,8 @@ class BoardManPanel: NSPanel {
private var glassBackgroundView: NSVisualEffectView?
private var searchField: NSSearchField?
private var segmentedControl: BoardManHeaderSegmentedControl?
private var historyUsageFilterControl: NSSegmentedControl?
private var historySortButton: NSButton?
private var settingsBackgroundView: NSView?
private var settingsSidebarView: NSView?
private var settingsCategoryButtons: [NSButton] = []
Expand Down Expand Up @@ -2488,6 +2526,40 @@ class BoardManPanel: NSPanel {
contentView.addSubview(tabs)
segmentedControl = tabs

let historyFilter = NSSegmentedControl(frame: .zero)
historyFilter.segmentCount = BoardManHistoryUsageFilter.allCases.count
historyFilter.trackingMode = .selectOne
historyFilter.segmentStyle = .rounded
historyFilter.controlSize = .small
historyFilter.target = self
historyFilter.action = #selector(historyUsageFilterChanged(_:))
let selectedUsageFilter = BoardManHistoryUsageFilter.allowed(
AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManHistoryUsageFilter)
)
for (index, filter) in BoardManHistoryUsageFilter.allCases.enumerated() {
historyFilter.setLabel(filter.rawValue, forSegment: index)
historyFilter.setToolTip(filter.toolTip, forSegment: index)
if #available(macOS 11.0, *) {
historyFilter.setLabel("", forSegment: index)
historyFilter.setImage(NSImage(systemSymbolName: filter.symbolName, accessibilityDescription: filter.rawValue), forSegment: index)
}
}
historyFilter.selectedSegment = BoardManHistoryUsageFilter.allCases.firstIndex(of: selectedUsageFilter) ?? 0
historyFilter.setAccessibilityLabel("History usage filter")
contentView.addSubview(historyFilter)
historyUsageFilterControl = historyFilter

let historySort = NSButton(title: "", target: self, action: #selector(historySortOrderChanged(_:)))
historySort.setButtonType(.toggle)
historySort.bezelStyle = .texturedRounded
historySort.controlSize = .small
historySort.imagePosition = .imageOnly
historySort.state = AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting) ? .on : .off
historySort.setAccessibilityLabel("History sort order")
contentView.addSubview(historySort)
historySortButton = historySort
updateHistorySortButton()

let settingsBackground = NSView(frame: .zero)
settingsBackground.wantsLayer = true
settingsBackground.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
Expand Down Expand Up @@ -3341,6 +3413,7 @@ class BoardManPanel: NSPanel {
? surfaceTint.withAlphaComponent(0.26)
: NSColor.controlBackgroundColor.withAlphaComponent(0.78)
segmentedControl?.needsDisplay = true
updateHistorySortButton()
settingsSidebarView?.layer?.backgroundColor = (useGlass
? surfaceTint.withAlphaComponent(0.28)
: NSColor.controlBackgroundColor.withAlphaComponent(simpleStyle ? 0.42 : 0.72)).cgColor
Expand Down Expand Up @@ -3510,6 +3583,22 @@ class BoardManPanel: NSPanel {
updateSnippetActionButtons()

let contentTop = headerY - 24
let showsHistoryToolbar = activeTab == .history && !isSettings
historyUsageFilterControl?.isHidden = !showsHistoryToolbar
historySortButton?.isHidden = !showsHistoryToolbar
let historyToolbarY = contentTop - 30
if showsHistoryToolbar {
let filterWidth: CGFloat = 114
historyUsageFilterControl?.frame = NSRect(x: margin, y: historyToolbarY, width: filterWidth, height: 26)
if let filter = historyUsageFilterControl {
let segmentWidth = floor(filterWidth / CGFloat(max(1, filter.segmentCount)))
for segment in 0..<filter.segmentCount {
filter.setWidth(segmentWidth, forSegment: segment)
}
}
historySortButton?.frame = NSRect(x: margin + filterWidth + 8, y: historyToolbarY, width: 32, height: 26)
}

let sidebarWidth: CGFloat = min(184, max(160, floor(width * 0.25)))
let settingsGap: CGFloat = 16
let settingsContentX = margin + sidebarWidth + settingsGap
Expand All @@ -3525,7 +3614,14 @@ class BoardManPanel: NSPanel {
let showsSnippetCategories = activeTab == .snippets && !isSettings
snippetEditorView?.isHidden = !showsSnippetCategories
let categoryRowY = contentTop - 38
let listTop = showsSnippetCategories ? categoryRowY - 16 : contentTop
let listTop: CGFloat
if showsSnippetCategories {
listTop = categoryRowY - 16
} else if showsHistoryToolbar {
listTop = historyToolbarY - 12
} else {
listTop = contentTop
}
let listHeight = max(190, listTop - 28)
[snippetCategoryLabel, snippetCategoryPopup, snippetCategoryAddButton, snippetCategoryRenameButton, snippetCategoryDeleteButton].forEach {
$0?.isHidden = !showsSnippetCategories
Expand Down Expand Up @@ -4652,6 +4748,7 @@ class BoardManPanel: NSPanel {

@objc private func reuseTopChanged(_ sender: NSButton) {
AppEnvironment.current.defaults.set(sender.state == .on, forKey: Constants.UserDefaults.reorderClipsAfterPasting)
updateHistorySortButton()
onRefreshRequested?()
}

Expand Down Expand Up @@ -4857,14 +4954,64 @@ class BoardManPanel: NSPanel {
applyCurrentFilter()
}

@objc private func historyUsageFilterChanged(_ sender: NSSegmentedControl) {
guard let filter = BoardManHistoryUsageFilter.allCases[safe: sender.selectedSegment] else { return }
AppEnvironment.current.defaults.set(filter.rawValue, forKey: Constants.UserDefaults.boardManHistoryUsageFilter)
selectedIndex = -1
hoveredRow = -1
hidePreviewBubble()
applyCurrentFilter()
}

@objc private func historySortOrderChanged(_ sender: NSButton) {
let usesRecentOrder = sender.state == .on
AppEnvironment.current.defaults.set(usesRecentOrder, forKey: Constants.UserDefaults.reorderClipsAfterPasting)
reuseTopButton?.state = usesRecentOrder ? .on : .off
selectedIndex = -1
hoveredRow = -1
hidePreviewBubble()
updateHistorySortButton()
onRefreshRequested?()
}

private func updateHistorySortButton() {
guard let button = historySortButton else { return }
let usesRecentOrder = AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.reorderClipsAfterPasting)
button.state = usesRecentOrder ? .on : .off
button.toolTip = usesRecentOrder
? "Recent Use — move pasted items to the top; newly copied items still appear first."
: "Copy Order — keep history fixed in the order items were copied."
if #available(macOS 11.0, *) {
let symbolName = usesRecentOrder ? "clock.arrow.circlepath" : "doc.on.doc"
let description = usesRecentOrder ? "Recent Use" : "Copy Order"
button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: description)
button.title = ""
button.imagePosition = .imageOnly
} else {
button.image = nil
button.title = usesRecentOrder ? "Use" : "Copy"
button.imagePosition = .noImage
}
if #available(macOS 10.14, *) {
button.contentTintColor = usesRecentOrder ? themeAccentColor : .secondaryLabelColor
}
}

private func applyCurrentFilter() {
let query = (searchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tabbedItems: [BoardManHistoryItem]
switch activeTab {
case .history:
let pinnedItems = allItems.filter { $0.isPinned && $0.isEnabled }
let regularHistory = allItems.filter { $0.source == .clip && !$0.isPinned }
tabbedItems = pinnedItems + regularHistory
let usageFilter = BoardManHistoryUsageFilter.allowed(
AppEnvironment.current.defaults.string(forKey: Constants.UserDefaults.boardManHistoryUsageFilter)
)
let filteredClips = allItems.filter { $0.source == .clip && $0.isEnabled && usageFilter.includes($0) }
let pinnedClips = filteredClips.filter { $0.isPinned }
let regularHistory = filteredClips.filter { !$0.isPinned }
let pinnedNonHistoryItems = usageFilter == .all
? allItems.filter { $0.source != .clip && $0.isPinned && $0.isEnabled }
: []
tabbedItems = pinnedClips + pinnedNonHistoryItems + regularHistory
case .snippets:
let snippetItems = allItems.filter { $0.source == .snippet }
if activeSnippetCategoryIdentifier == BoardManPanel.allCategoriesIdentifier {
Expand Down
1 change: 1 addition & 0 deletions Clipy/Sources/Models/CPYClip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class CPYClip: Object {
@objc dynamic var title = ""
@objc dynamic var dataHash = ""
@objc dynamic var primaryType = ""
@objc dynamic var createdTime = 0
@objc dynamic var updateTime = 0
@objc dynamic var thumbnailPath = ""
@objc dynamic var isColorCode = false
Expand Down
1 change: 1 addition & 0 deletions Clipy/Sources/Services/ClipService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ extension ClipService {
let title = data.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (data.imageDisplayTitle ?? data.stringValue) : data.stringValue
clip.title = title[0...10000]
clip.dataHash = "\(savedHash)"
clip.createdTime = Int(Date().timeIntervalSince1970 * 1000)
clip.updateTime = unixTime
clip.primaryType = data.primaryType?.rawValue ?? ""

Expand Down
1 change: 1 addition & 0 deletions Clipy/Sources/Utility/CPYUtilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ final class CPYUtilities {
defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.showColorPreviewInTheMenu)
defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.boardManUsePanelUI)
defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.boardManShowRowNumbers)
defaultValues.updateValue("All", forKey: Constants.UserDefaults.boardManHistoryUsageFilter)
defaultValues.updateValue("relative", forKey: Constants.UserDefaults.boardManTimestampFormat)
defaultValues.updateValue(NSNumber(value: 680), forKey: Constants.UserDefaults.boardManPanelHeight)
defaultValues.updateValue(NSNumber(value: true), forKey: Constants.UserDefaults.boardManShowUsageCount)
Expand Down
64 changes: 64 additions & 0 deletions ClipyTests/EntitlementGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,42 @@ struct PasteCountInputServiceTests {
) == nil)
}

@Test
func recentUseOrderDoesNotOverwriteCopyOrder() throws {
var configuration = Realm.Configuration(inMemoryIdentifier: UUID().uuidString)
configuration.objectTypes = [CPYClip.self]
let realm = try Realm(configuration: configuration)
let defaultsSuite = "BoardManHistoryOrderTests-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: defaultsSuite))
defer { defaults.removePersistentDomain(forName: defaultsSuite) }

let olderClip = CPYClip()
olderClip.dataHash = "older"
olderClip.createdTime = 1_000
olderClip.updateTime = 100

let newerClip = CPYClip()
newerClip.dataHash = "newer"
newerClip.createdTime = 2_000
newerClip.updateTime = 200

try realm.write {
realm.add([olderClip, newerClip])
}

#expect(realm.objects(CPYClip.self)
.sorted(byKeyPath: #keyPath(CPYClip.createdTime), ascending: false)
.first?.dataHash == "newer")
#expect(PasteCountStore(defaults: defaults).markUsed(clip: olderClip, in: realm))
#expect(olderClip.createdTime == 1_000)
#expect(realm.objects(CPYClip.self)
.sorted(byKeyPath: #keyPath(CPYClip.updateTime), ascending: false)
.first?.dataHash == "older")
#expect(realm.objects(CPYClip.self)
.sorted(byKeyPath: #keyPath(CPYClip.createdTime), ascending: false)
.first?.dataHash == "newer")
}

@Test
func imageFingerprintSurvivesArchiveRoundTripAndDistinguishesPixels() throws {
let firstImage = testImage(color: .systemRed)
Expand Down Expand Up @@ -465,13 +501,15 @@ final class BoardManPanelLayoutTests {
#expect(panel.presentationItemScope == .historyOnly)
assertTopLevelLayout(panel, mode: "History", expectsSearch: true)
assertHeaderChrome(panel, expectsSearch: true)
assertHistoryToolbar(panel, expectsVisible: true)
assertHistoryRowGeometry(panel)

panel.openSnippetsManagerMode()
await settlePanelLayout(panel)
#expect(panel.presentationItemScope == .complete)
assertTopLevelLayout(panel, mode: "Snippets", expectsSearch: true)
assertHeaderChrome(panel, expectsSearch: true)
assertHistoryToolbar(panel, expectsVisible: false)
assertHistoryRowGeometry(panel)

panel.selectSettingsTab()
Expand Down Expand Up @@ -582,6 +620,32 @@ final class BoardManPanelLayoutTests {
}
}

private func assertHistoryToolbar(_ panel: BoardManPanel, expectsVisible: Bool) {
guard let root = panel.contentView else {
Issue.record("Missing content view while checking the history toolbar.")
return
}
let descendants = allSubviews(of: root)
let usageFilter = descendants
.compactMap { $0 as? NSSegmentedControl }
.first { !($0 is BoardManHeaderSegmentedControl) && $0.segmentCount == 3 }
let sortButton = descendants
.compactMap { $0 as? NSButton }
.first { ($0.toolTip ?? "").contains("Copy Order") || ($0.toolTip ?? "").contains("Recent Use") }

#expect(usageFilter != nil, "History usage filter was not created.")
#expect(sortButton != nil, "History sort toggle was not created.")
#expect(usageFilter?.isHidden == !expectsVisible)
#expect(sortButton?.isHidden == !expectsVisible)
if expectsVisible, let usageFilter {
#expect(usageFilter.selectedSegment >= 0 && usageFilter.selectedSegment < 3)
for segment in 0..<usageFilter.segmentCount {
#expect(!(usageFilter.toolTip(forSegment: segment) ?? "").isEmpty,
"History usage filter segment is missing its hover explanation.")
}
}
}

private func assertHistoryRowGeometry(_ panel: BoardManPanel) {
guard let root = panel.contentView,
let table = allSubviews(of: root).compactMap({ $0 as? NSTableView }).first else {
Expand Down
Loading