Slim down app and test suite
This commit is contained in:
@@ -24,6 +24,7 @@ Run the full local check before opening a pull request:
|
||||
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
|
||||
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
|
||||
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
|
||||
- Prefer behavior-level tests. Use the smoke checklist for visual layout instead of adding production debug accessors for private UI details.
|
||||
- Keep UI native and compact. This is a utility, not a marketing surface.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
@@ -90,7 +90,7 @@ Project layout:
|
||||
- `sources/clipbored/resources` - app bundle metadata and icon assets
|
||||
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
|
||||
- `sources/clipbored/views` - panel, onboarding, preview, and settings UI
|
||||
- `tests/clipboredtests` - unit and UI-structure regression tests
|
||||
- `tests/clipboredtests` - focused behavior tests for capture, persistence, search, paste, and settings decisions
|
||||
- `docs` - architecture, security, release, smoke-test, and roadmap notes
|
||||
|
||||
## Privacy And Security
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# UI Cleanup Resolution
|
||||
|
||||
All eleven findings from the original cleanup list are resolved in the current shelf design.
|
||||
|
||||
1. [x] **Hover controls obscured clip text.** Hover now expands the preview visually; commands are exposed through the context menu and keyboard, so no hover rail covers content.
|
||||
2. [x] **Hover broke keyboard navigation.** Hover state is independent from keyboard focus and selection, and arrow navigation clears stale hover ownership without changing the wrong clip.
|
||||
3. [x] **Category changes felt abrupt.** Search, category, card, and panel changes use short eased transitions and become immediate when macOS Reduce Motion is enabled.
|
||||
4. [x] **The collapsed search control was broken.** Search has one aligned container: it expands on click, typing, or `Command + F`, and collapses after click-away only when the query is empty.
|
||||
5. [x] **Filtering was split across categories and a search menu.** Category chips are the primary visual filters; a click replaces the filter and Command-click builds a union. `Command + F` only focuses search.
|
||||
6. [x] **Empty built-in categories added noise.** Built-in type/sort chips are created only when they have matches or are selected; empty custom Pinboards remain visible by design.
|
||||
7. [x] **A nonfunctional resize lip was visible.** The shelf has no resize handle and uses its fixed side-shelf frame.
|
||||
8. [x] **New Text Clip was unnecessary.** The panel, menu-bar menu, and shortcut map no longer expose a new-text action.
|
||||
9. [x] **The alternate compact mode was unnecessary.** Settings exposes one Side Shelf layout; row sizing adapts automatically to available space instead of presenting a mode toggle.
|
||||
10. [x] **The panel did not need a close control.** There is no close button in shelf chrome; `Esc` and the configured global shortcut dismiss it.
|
||||
11. [x] **The persistent status bar consumed space.** The shelf flows directly from its toolbar into the side-rail card list; feedback is shown in the relevant menu or Settings page.
|
||||
@@ -62,9 +62,3 @@ extension NSImage {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSView {
|
||||
var isInAnyViewHierarchy: Bool {
|
||||
return window != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,14 +61,6 @@ enum ColorPayload {
|
||||
"\(displayHex(from: payload))\n\(componentSummary(from: payload))"
|
||||
}
|
||||
|
||||
static func contrastingTextColor(for color: NSColor) -> NSColor {
|
||||
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
|
||||
return .labelColor
|
||||
}
|
||||
let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent)
|
||||
return luminance > 0.62 ? NSColor.black.withAlphaComponent(0.82) : .white
|
||||
}
|
||||
|
||||
private static func clampedByte(_ value: CGFloat) -> Int {
|
||||
Int((min(1, max(0, value)) * 255).rounded())
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -329,7 +329,6 @@ final class ClipboardCacheService {
|
||||
|
||||
func purgeIfNeeded(maxBytes: Int64) {
|
||||
queue.async {
|
||||
DiagnosticsService.shared.incrementCachePurge()
|
||||
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
|
||||
var items: [(url: URL, size: Int64, date: Date)] = []
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
@@ -108,7 +108,6 @@ final class ClipboardMonitorService {
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
DiagnosticsService.shared.incrementMonitorTick()
|
||||
pollPasteboard(rescheduleAfterCapture: true)
|
||||
}
|
||||
|
||||
@@ -138,7 +137,6 @@ final class ClipboardMonitorService {
|
||||
return
|
||||
}
|
||||
|
||||
DiagnosticsService.shared.incrementPasteboardChange()
|
||||
|
||||
didReportReadFailure = false
|
||||
if let item = readCurrentItem(from: pasteboard) {
|
||||
@@ -162,7 +160,6 @@ final class ClipboardMonitorService {
|
||||
}
|
||||
|
||||
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
|
||||
DiagnosticsService.shared.incrementExtractionAttempt()
|
||||
let source = frontmostApp()
|
||||
|
||||
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
|
||||
|
||||
@@ -659,78 +659,22 @@ final class ClipboardStore {
|
||||
}
|
||||
|
||||
private func legacyISO8601Date(_ string: String) -> Date? {
|
||||
string.withCString { pointer -> Date? in
|
||||
let byteCount = strlen(pointer)
|
||||
guard byteCount >= 20,
|
||||
byte(pointer, 4) == 45,
|
||||
byte(pointer, 7) == 45,
|
||||
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
|
||||
byte(pointer, 13) == 58,
|
||||
byte(pointer, 16) == 58,
|
||||
let year = decimal(pointer, byteCount, 0, 4),
|
||||
let month = decimal(pointer, byteCount, 5, 2),
|
||||
let day = decimal(pointer, byteCount, 8, 2),
|
||||
let hour = decimal(pointer, byteCount, 11, 2),
|
||||
let minute = decimal(pointer, byteCount, 14, 2),
|
||||
let second = decimal(pointer, byteCount, 17, 2)
|
||||
else {
|
||||
return nil
|
||||
let value = string.replacingOccurrences(of: " ", with: "T")
|
||||
return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first
|
||||
}
|
||||
|
||||
var cursor = 19
|
||||
var fraction = 0.0
|
||||
if cursor < byteCount, byte(pointer, cursor) == 46 {
|
||||
cursor += 1
|
||||
var scale = 0.1
|
||||
while cursor < byteCount {
|
||||
let digit = byte(pointer, cursor)
|
||||
guard digit >= 48, digit <= 57 else { break }
|
||||
fraction += Double(digit - 48) * scale
|
||||
scale /= 10
|
||||
cursor += 1
|
||||
}
|
||||
private static let legacyDateFormatters: [ISO8601DateFormatter] = {
|
||||
let formats: [ISO8601DateFormatter.Options] = [
|
||||
[.withInternetDateTime, .withFractionalSeconds],
|
||||
[.withInternetDateTime]
|
||||
]
|
||||
return formats.map { options in
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = options
|
||||
return formatter
|
||||
}
|
||||
}()
|
||||
|
||||
var offset = 0
|
||||
if cursor < byteCount, byte(pointer, cursor) == 90 {
|
||||
offset = 0
|
||||
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
|
||||
let sign = byte(pointer, cursor) == 43 ? 1 : -1
|
||||
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
|
||||
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
|
||||
else { return nil }
|
||||
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
|
||||
}
|
||||
|
||||
var components = tm()
|
||||
components.tm_year = Int32(year - 1900)
|
||||
components.tm_mon = Int32(month - 1)
|
||||
components.tm_mday = Int32(day)
|
||||
components.tm_hour = Int32(hour)
|
||||
components.tm_min = Int32(minute)
|
||||
components.tm_sec = Int32(second)
|
||||
components.tm_isdst = 0
|
||||
|
||||
let epoch = timegm(&components)
|
||||
guard epoch >= 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
|
||||
}
|
||||
}
|
||||
|
||||
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
|
||||
guard start + length <= byteCount else { return nil }
|
||||
var result = 0
|
||||
for index in start..<(start + length) {
|
||||
let digit = byte(pointer, index)
|
||||
guard digit >= 48, digit <= 57 else { return nil }
|
||||
result = (result * 10) + Int(digit - 48)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
|
||||
UInt8(bitPattern: pointer[index])
|
||||
}
|
||||
|
||||
private func isDatabaseEmpty() -> Bool {
|
||||
guard let db else { return true }
|
||||
@@ -901,7 +845,6 @@ final class ClipboardStore {
|
||||
|
||||
private func applyPersistence(_ mutation: PersistenceMutation) {
|
||||
guard let db else { return }
|
||||
DiagnosticsService.shared.incrementDatabaseMutation()
|
||||
let insertSQL = """
|
||||
INSERT OR REPLACE INTO clipboard_items (
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class DiagnosticsService {
|
||||
static let shared = DiagnosticsService()
|
||||
|
||||
struct Snapshot: Equatable {
|
||||
var monitorTicks: Int
|
||||
var pasteboardChanges: Int
|
||||
var extractionAttempts: Int
|
||||
var databaseMutations: Int
|
||||
var cachePurges: Int
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility)
|
||||
private var snapshot = Snapshot(
|
||||
monitorTicks: 0,
|
||||
pasteboardChanges: 0,
|
||||
extractionAttempts: 0,
|
||||
databaseMutations: 0,
|
||||
cachePurges: 0
|
||||
)
|
||||
|
||||
private init() {}
|
||||
|
||||
func incrementMonitorTick() {
|
||||
queue.async { self.snapshot.monitorTicks += 1 }
|
||||
}
|
||||
|
||||
func incrementPasteboardChange() {
|
||||
queue.async { self.snapshot.pasteboardChanges += 1 }
|
||||
}
|
||||
|
||||
func incrementExtractionAttempt() {
|
||||
queue.async { self.snapshot.extractionAttempts += 1 }
|
||||
}
|
||||
|
||||
func incrementDatabaseMutation() {
|
||||
queue.async { self.snapshot.databaseMutations += 1 }
|
||||
}
|
||||
|
||||
func incrementCachePurge() {
|
||||
queue.async { self.snapshot.cachePurges += 1 }
|
||||
}
|
||||
|
||||
func currentSnapshot() -> Snapshot {
|
||||
queue.sync { snapshot }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
queue.sync {
|
||||
snapshot = Snapshot(
|
||||
monitorTicks: 0,
|
||||
pasteboardChanges: 0,
|
||||
extractionAttempts: 0,
|
||||
databaseMutations: 0,
|
||||
cachePurges: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,369 +17,143 @@ enum SensitiveContentDetector {
|
||||
case keyword
|
||||
}
|
||||
|
||||
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? {
|
||||
let trimmed = text.clipboardTrimmed
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let bytes = Array(trimmed.utf8)
|
||||
private static let tokenPatterns: [(Reason, NSRegularExpression)] = [
|
||||
(.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)),
|
||||
(.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)),
|
||||
(.slackToken, regex(#"\bxox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9_])"#)),
|
||||
(.awsAccessKey, regex(#"\bAKIA[A-Z0-9]{16}(?![A-Za-z0-9_])"#)),
|
||||
(.stripeKey, regex(#"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9_])"#)),
|
||||
(.openAIToken, regex(#"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])"#)),
|
||||
(.googleAPIKey, regex(#"\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_])"#)),
|
||||
(.jsonWebToken, regex(#"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])"#))
|
||||
]
|
||||
|
||||
if containsPrivateKey(trimmed) { return .privateKey }
|
||||
if containsBearerToken(bytes) { return .bearerToken }
|
||||
if containsGitHubToken(bytes) { return .githubToken }
|
||||
if containsSlackToken(bytes) { return .slackToken }
|
||||
if containsAWSAccessKey(bytes) { return .awsAccessKey }
|
||||
if containsStripeKey(bytes) { return .stripeKey }
|
||||
if containsOpenAIToken(bytes) { return .openAIToken }
|
||||
if containsGoogleAPIKey(bytes) { return .googleAPIKey }
|
||||
if containsJSONWebToken(bytes) { return .jsonWebToken }
|
||||
if containsCreditCard(trimmed) { return .creditCard }
|
||||
if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode }
|
||||
if looksHighEntropy(trimmed) { return .highEntropyToken }
|
||||
static func detect(
|
||||
_ text: String,
|
||||
sourceBundleId: String? = nil,
|
||||
sourceApp: String? = nil
|
||||
) -> Reason? {
|
||||
let value = text.clipboardTrimmed
|
||||
guard !value.isEmpty else { return nil }
|
||||
|
||||
let lowered = trimmed.lowercased()
|
||||
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) {
|
||||
if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") {
|
||||
return .privateKey
|
||||
}
|
||||
if let match = tokenPatterns.first(where: { matches($0.1, in: value) }) {
|
||||
return match.0
|
||||
}
|
||||
if containsCreditCard(value) { return .creditCard }
|
||||
if looksLikeOneTimeCode(value, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
|
||||
return .oneTimeCode
|
||||
}
|
||||
if looksHighEntropy(value) { return .highEntropyToken }
|
||||
|
||||
let lowered = value.lowercased()
|
||||
if lowered.contains("password")
|
||||
|| lowered.contains("secret")
|
||||
|| lowered.contains("api_key")
|
||||
|| looksLikeSecretAssignment(lowered) {
|
||||
return .keyword
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool {
|
||||
static func isLikelySensitive(
|
||||
_ text: String,
|
||||
sourceBundleId: String? = nil,
|
||||
sourceApp: String? = nil
|
||||
) -> Bool {
|
||||
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
|
||||
}
|
||||
|
||||
private static func containsPrivateKey(_ text: String) -> Bool {
|
||||
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----")
|
||||
private static func regex(_ pattern: String) -> NSRegularExpression {
|
||||
try! NSRegularExpression(pattern: pattern)
|
||||
}
|
||||
|
||||
private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool {
|
||||
regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
|
||||
}
|
||||
|
||||
private static func looksHighEntropy(_ text: String) -> Bool {
|
||||
let candidate = text.clipboardTrimmed
|
||||
guard candidate.count >= 32, candidate.count <= 256 else { return false }
|
||||
guard !candidate.contains(where: { $0.isWhitespace }) else { return false }
|
||||
|
||||
var hasLower = false
|
||||
var hasUpper = false
|
||||
var hasDigit = false
|
||||
var symbolCount = 0
|
||||
|
||||
for scalar in candidate.unicodeScalars {
|
||||
let value = scalar.value
|
||||
if value >= 48, value <= 57 {
|
||||
hasDigit = true
|
||||
} else if value >= 65, value <= 90 {
|
||||
hasUpper = true
|
||||
} else if value >= 97, value <= 122 {
|
||||
hasLower = true
|
||||
} else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 {
|
||||
symbolCount += 1
|
||||
} else {
|
||||
guard (32...256).contains(text.count),
|
||||
!text.contains(where: \.isWhitespace) else {
|
||||
return false
|
||||
}
|
||||
|
||||
var characterClasses = 0
|
||||
var hasSymbol = false
|
||||
for scalar in text.unicodeScalars {
|
||||
switch scalar.value {
|
||||
case 48...57: characterClasses |= 1
|
||||
case 65...90: characterClasses |= 2
|
||||
case 97...122: characterClasses |= 4
|
||||
case 43, 45, 46, 47, 61, 95: hasSymbol = true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
return characterClasses.nonzeroBitCount >= 2 && hasSymbol
|
||||
}
|
||||
|
||||
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0)
|
||||
return classCount >= 2 && symbolCount > 0
|
||||
private static func looksLikeOneTimeCode(
|
||||
_ text: String,
|
||||
sourceBundleId: String?,
|
||||
sourceApp: String?
|
||||
) -> Bool {
|
||||
guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else {
|
||||
return false
|
||||
}
|
||||
|
||||
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool {
|
||||
let value = text.clipboardTrimmed
|
||||
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false }
|
||||
|
||||
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased()
|
||||
guard !source.isEmpty else { return false }
|
||||
return source.contains("auth") ||
|
||||
source.contains("1password") ||
|
||||
source.contains("bitwarden") ||
|
||||
source.contains("lastpass") ||
|
||||
source.contains("keeper") ||
|
||||
source.contains("dashlane")
|
||||
let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased()
|
||||
return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"]
|
||||
.contains(where: source.contains)
|
||||
}
|
||||
|
||||
private static func containsCreditCard(_ text: String) -> Bool {
|
||||
var digits: [Int] = []
|
||||
|
||||
for char in text {
|
||||
if char.isNumber, let digit = char.wholeNumberValue {
|
||||
digits.append(digit)
|
||||
} else {
|
||||
if isCreditCardGroup(digits) {
|
||||
return true
|
||||
func isCard(_ digits: [Int]) -> Bool {
|
||||
guard (13...19).contains(digits.count),
|
||||
let first = digits.first,
|
||||
digits.contains(where: { $0 != first }) else {
|
||||
return false
|
||||
}
|
||||
var sum = 0
|
||||
for (index, digit) in digits.reversed().enumerated() {
|
||||
let doubled = index.isMultiple(of: 2) ? digit : digit * 2
|
||||
sum += doubled > 9 ? doubled - 9 : doubled
|
||||
}
|
||||
return sum.isMultiple(of: 10)
|
||||
}
|
||||
|
||||
for character in text {
|
||||
if let digit = character.wholeNumberValue {
|
||||
digits.append(digit)
|
||||
} else if (character == " " || character == "-"), !digits.isEmpty {
|
||||
continue
|
||||
} else {
|
||||
if isCard(digits) { return true }
|
||||
digits.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
return isCreditCardGroup(digits)
|
||||
return isCard(digits)
|
||||
}
|
||||
|
||||
private static func isCreditCardGroup(_ digits: [Int]) -> Bool {
|
||||
guard digits.count >= 13, digits.count <= 19, let first = digits.first else {
|
||||
return false
|
||||
}
|
||||
guard digits.contains(where: { $0 != first }) else {
|
||||
return false
|
||||
}
|
||||
return passesLuhn(digits)
|
||||
}
|
||||
|
||||
private static func passesLuhn(_ digits: [Int]) -> Bool {
|
||||
var sum = 0
|
||||
var shouldDouble = false
|
||||
|
||||
for digit in digits.reversed() {
|
||||
var value = digit
|
||||
if shouldDouble {
|
||||
value *= 2
|
||||
if value > 9 {
|
||||
value -= 9
|
||||
}
|
||||
}
|
||||
sum += value
|
||||
shouldDouble.toggle()
|
||||
}
|
||||
|
||||
return sum % 10 == 0
|
||||
}
|
||||
|
||||
private static func containsBearerToken(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 27 else { return false }
|
||||
for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) {
|
||||
guard matchesBearer(bytes, index) else { continue }
|
||||
var cursor = index + 6
|
||||
guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue }
|
||||
while cursor < bytes.count, isWhitespace(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
let start = cursor
|
||||
while cursor < bytes.count, isBearerByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 34 else { return false }
|
||||
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
|
||||
let marker = bytes[index + 2]
|
||||
guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else {
|
||||
continue
|
||||
}
|
||||
var cursor = index + 4
|
||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsSlackToken(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 25 else { return false }
|
||||
for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) {
|
||||
let marker = bytes[index + 3]
|
||||
guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else {
|
||||
continue
|
||||
}
|
||||
var cursor = index + 5
|
||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 20 else { return false }
|
||||
for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) {
|
||||
guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue }
|
||||
var cursor = index + 4
|
||||
while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsStripeKey(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 24 else { return false }
|
||||
for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) {
|
||||
let prefix = bytes[index]
|
||||
guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue }
|
||||
let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95
|
||||
let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95
|
||||
guard live || test else { continue }
|
||||
var cursor = index + 8
|
||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 24 else { return false }
|
||||
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
|
||||
guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue }
|
||||
var cursor = index + 3
|
||||
if cursor + 5 <= bytes.count,
|
||||
bytes[cursor] == 112,
|
||||
bytes[cursor + 1] == 114,
|
||||
bytes[cursor + 2] == 111,
|
||||
bytes[cursor + 3] == 106,
|
||||
bytes[cursor + 4] == 45 {
|
||||
cursor += 5
|
||||
}
|
||||
let tokenStart = cursor
|
||||
while cursor < bytes.count, isTokenByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 39 else { return false }
|
||||
for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) {
|
||||
guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue }
|
||||
var cursor = index + 4
|
||||
while cursor < index + 39, isTokenByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool {
|
||||
guard bytes.count >= 32 else { return false }
|
||||
var index = 0
|
||||
while index + 3 < bytes.count {
|
||||
guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
var cursor = index
|
||||
let firstStart = cursor
|
||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
cursor += 1
|
||||
let secondStart = cursor
|
||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
cursor += 1
|
||||
let thirdStart = cursor
|
||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) {
|
||||
return true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func looksLikeSecretAssignment(_ lowered: String) -> Bool {
|
||||
private static func looksLikeSecretAssignment(_ text: String) -> Bool {
|
||||
let keys = [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_token",
|
||||
"auth_token",
|
||||
"client_secret",
|
||||
"private_token",
|
||||
"refresh_token",
|
||||
"secret_key",
|
||||
"passwd"
|
||||
"api_key", "apikey", "access_token", "auth_token", "client_secret",
|
||||
"private_token", "refresh_token", "secret_key", "passwd"
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
guard let range = lowered.range(of: key) else { continue }
|
||||
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace })
|
||||
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue }
|
||||
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" })
|
||||
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count
|
||||
if valueLength >= 8 {
|
||||
guard let range = text.range(of: key) else { continue }
|
||||
let suffix = text[range.upperBound...].drop(while: \.isWhitespace)
|
||||
guard suffix.first == "=" || suffix.first == ":" else { continue }
|
||||
let value = suffix.dropFirst().drop {
|
||||
$0.isWhitespace || $0 == "\"" || $0 == "'"
|
||||
}
|
||||
if value.prefix(while: {
|
||||
!$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != ","
|
||||
}).count >= 8 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool {
|
||||
(bytes[index] == 98 || bytes[index] == 66) &&
|
||||
(bytes[index + 1] == 101 || bytes[index + 1] == 69) &&
|
||||
(bytes[index + 2] == 97 || bytes[index + 2] == 65) &&
|
||||
(bytes[index + 3] == 114 || bytes[index + 3] == 82) &&
|
||||
(bytes[index + 4] == 101 || bytes[index + 4] == 69) &&
|
||||
(bytes[index + 5] == 114 || bytes[index + 5] == 82)
|
||||
}
|
||||
|
||||
private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool {
|
||||
index == 0 || !isWordByte(bytes[index - 1])
|
||||
}
|
||||
|
||||
private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool {
|
||||
index >= bytes.count || !isWordByte(bytes[index])
|
||||
}
|
||||
|
||||
private static func isWordByte(_ byte: UInt8) -> Bool {
|
||||
isAlphaNumeric(byte) || byte == 95
|
||||
}
|
||||
|
||||
private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
|
||||
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)
|
||||
}
|
||||
|
||||
private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool {
|
||||
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90)
|
||||
}
|
||||
|
||||
private static func isBearerByte(_ byte: UInt8) -> Bool {
|
||||
isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61
|
||||
}
|
||||
|
||||
private static func isTokenByte(_ byte: UInt8) -> Bool {
|
||||
isAlphaNumeric(byte) || byte == 95 || byte == 45
|
||||
}
|
||||
|
||||
private static func isBase64URLByte(_ byte: UInt8) -> Bool {
|
||||
isAlphaNumeric(byte) || byte == 95 || byte == 45
|
||||
}
|
||||
|
||||
private static func isWhitespace(_ byte: UInt8) -> Bool {
|
||||
byte == 32 || byte == 9 || byte == 10 || byte == 13
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,10 +261,6 @@ final class ShortcutManager {
|
||||
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
|
||||
)
|
||||
|
||||
static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] {
|
||||
[openShortcut, stackCaptureShortcut]
|
||||
}
|
||||
|
||||
private func osStatusMessage(_ status: OSStatus) -> String {
|
||||
"OSStatus \(status)"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import AppKit
|
||||
import QuickLookUI
|
||||
|
||||
struct ClipboardPanelAnimationProfile {
|
||||
let showDuration: TimeInterval
|
||||
let hideDuration: TimeInterval
|
||||
let reflowDuration: TimeInterval
|
||||
let easing: CAMediaTimingFunctionName
|
||||
}
|
||||
|
||||
struct ClipboardPanelReflowPlan {
|
||||
let frame: NSRect
|
||||
let bottomSafeInset: CGFloat
|
||||
@@ -360,10 +353,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
|
||||
}
|
||||
|
||||
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
||||
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
}
|
||||
|
||||
static func panelFrames(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
@@ -422,27 +411,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
|
||||
}
|
||||
|
||||
static var animationProfile: ClipboardPanelAnimationProfile {
|
||||
ClipboardPanelAnimationProfile(
|
||||
showDuration: Animation.showDuration,
|
||||
hideDuration: Animation.hideDuration,
|
||||
reflowDuration: Animation.reflowDuration,
|
||||
easing: Animation.easing
|
||||
)
|
||||
}
|
||||
|
||||
static var panelCollectionBehavior: NSWindow.CollectionBehavior {
|
||||
[.moveToActiveSpace, .fullScreenAuxiliary, .transient]
|
||||
}
|
||||
|
||||
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
|
||||
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
return ClipboardPanelReflowPlan(
|
||||
frame: frames.shown,
|
||||
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
)
|
||||
}
|
||||
|
||||
static func reflowPlan(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
@@ -848,47 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
quickLookURL as NSURL?
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugPanelFrame: NSRect {
|
||||
panel.frame
|
||||
}
|
||||
|
||||
var debugPanelAlpha: CGFloat {
|
||||
panel.alphaValue
|
||||
}
|
||||
|
||||
var debugIsAnimating: Bool {
|
||||
isAnimating
|
||||
}
|
||||
|
||||
func debugSetSearchFieldText(_ text: String) {
|
||||
panelView.debugSetSearchFieldText(text)
|
||||
}
|
||||
|
||||
var debugSearchFieldText: String {
|
||||
panelView.debugSearchFieldText
|
||||
}
|
||||
|
||||
var debugSearchFieldWidth: CGFloat {
|
||||
panelView.debugSearchFieldWidth
|
||||
}
|
||||
|
||||
var debugSearchFieldPlaceholderText: String {
|
||||
panelView.debugSearchFieldPlaceholderText
|
||||
}
|
||||
|
||||
var debugSearchFieldIsVisible: Bool {
|
||||
panelView.debugSearchFieldIsVisible
|
||||
}
|
||||
|
||||
var debugSearchIconButtonIsVisible: Bool {
|
||||
panelView.debugSearchIconButtonIsVisible
|
||||
}
|
||||
|
||||
var debugIsSearchFieldEditing: Bool {
|
||||
panelView.isSearchFieldEditing
|
||||
}
|
||||
#endif
|
||||
|
||||
private func installClickMonitor() {
|
||||
removeClickMonitor()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,18 @@ final class ClipboardPanelViewModel {
|
||||
case original
|
||||
}
|
||||
|
||||
private enum TransferIntent {
|
||||
case copy
|
||||
case paste
|
||||
}
|
||||
|
||||
private enum TransferContext {
|
||||
case single
|
||||
case selected
|
||||
case stackItem
|
||||
case stackText
|
||||
}
|
||||
|
||||
private(set) var visibleItems: [ClipboardItem] = [] {
|
||||
didSet {
|
||||
var nextItemByID: [UUID: ClipboardItem] = [:]
|
||||
@@ -324,18 +336,6 @@ final class ClipboardPanelViewModel {
|
||||
var onStackChanged: (() -> Void)?
|
||||
var onCaptureStatusChanged: (() -> Void)?
|
||||
|
||||
#if DEBUG
|
||||
private(set) var debugVisibleItemsFullScanCount = 0
|
||||
private(set) var debugVisibleItemsIndexedLookupCount = 0
|
||||
private(set) var debugCollectionCountFullScanCount = 0
|
||||
private(set) var debugCollectionCountIndexedLookupCount = 0
|
||||
private(set) var debugSearchItemEvaluationCount = 0
|
||||
private(set) var debugSearchMatchCacheHitCount = 0
|
||||
private(set) var debugSearchDocumentBuildCount = 0
|
||||
private(set) var debugSearchDocumentCacheHitCount = 0
|
||||
private(set) var debugCategoryFilterSelectionBuildCount = 0
|
||||
private(set) var debugStackPruneScanCount = 0
|
||||
#endif
|
||||
|
||||
init(
|
||||
store: ClipboardStore,
|
||||
@@ -392,20 +392,6 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func debugResetVisibleItemsPerformanceCounters() {
|
||||
debugVisibleItemsFullScanCount = 0
|
||||
debugVisibleItemsIndexedLookupCount = 0
|
||||
debugCollectionCountFullScanCount = 0
|
||||
debugCollectionCountIndexedLookupCount = 0
|
||||
debugSearchItemEvaluationCount = 0
|
||||
debugSearchMatchCacheHitCount = 0
|
||||
debugSearchDocumentBuildCount = 0
|
||||
debugSearchDocumentCacheHitCount = 0
|
||||
debugCategoryFilterSelectionBuildCount = 0
|
||||
debugStackPruneScanCount = 0
|
||||
}
|
||||
#endif
|
||||
|
||||
var selectedItem: ClipboardItem? {
|
||||
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
|
||||
@@ -445,10 +431,6 @@ final class ClipboardPanelViewModel {
|
||||
stackItemIDs.count
|
||||
}
|
||||
|
||||
var stackTitle: String {
|
||||
"Stack"
|
||||
}
|
||||
|
||||
var collectionNames: [String] {
|
||||
if let collectionNamesCache {
|
||||
return collectionNamesCache
|
||||
@@ -473,14 +455,6 @@ final class ClipboardPanelViewModel {
|
||||
return names
|
||||
}
|
||||
|
||||
var searchFilterSourceAppNames: [String] {
|
||||
uniqueSearchFacetValues(items.compactMap(\.sourceApp))
|
||||
}
|
||||
|
||||
var searchFilterDeviceNames: [String] {
|
||||
uniqueSearchFacetValues(items.map { Optional(effectiveSourceDeviceName(for: $0)) })
|
||||
}
|
||||
|
||||
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
|
||||
collectionCountSummary().count(for: sortMode)
|
||||
}
|
||||
@@ -505,9 +479,6 @@ final class ClipboardPanelViewModel {
|
||||
for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
|
||||
collectionCounts[collectionKey] = indexedItems.count
|
||||
}
|
||||
#if DEBUG
|
||||
debugCollectionCountIndexedLookupCount += 1
|
||||
#endif
|
||||
} else {
|
||||
for indexedItem in indexedItemsMatchingSearch(query) {
|
||||
let item = indexedItem.item
|
||||
@@ -516,9 +487,6 @@ final class ClipboardPanelViewModel {
|
||||
collectionCounts[collectionName.lowercased(), default: 0] += 1
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
debugCollectionCountFullScanCount += 1
|
||||
#endif
|
||||
}
|
||||
|
||||
let summary = ClipboardCollectionCountSummary(
|
||||
@@ -673,83 +641,35 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
|
||||
func pasteSelected() {
|
||||
if selectedItemCount > 1 {
|
||||
pasteSelectedItems()
|
||||
return
|
||||
}
|
||||
guard let item = selectedItem else { return }
|
||||
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
|
||||
if case .pasted = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
if case .failed = result {} else {
|
||||
store.markUsed(item.id)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
statusMessage = result.message
|
||||
settings.setPasteStatus(message: result.message)
|
||||
performSelectedTransfer(.paste)
|
||||
}
|
||||
|
||||
func pasteSelectedPlainText() {
|
||||
if selectedItemCount > 1 {
|
||||
pasteSelectedItemsAsText()
|
||||
return
|
||||
}
|
||||
guard let item = selectedItem else { return }
|
||||
let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
|
||||
if case .pastedPlainText = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
if case .failed = result {} else {
|
||||
store.markUsed(item.id)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
statusMessage = result.message
|
||||
settings.setPasteStatus(message: result.message)
|
||||
performSelectedTransfer(.paste, asPlainText: true)
|
||||
}
|
||||
|
||||
func pasteItem(at index: Int) {
|
||||
guard index >= 0 && index < visibleItems.count else { return }
|
||||
guard visibleItems.indices.contains(index) else { return }
|
||||
selectItem(at: index)
|
||||
pasteSelected()
|
||||
}
|
||||
|
||||
func pasteItemPlainText(at index: Int) {
|
||||
guard index >= 0 && index < visibleItems.count else { return }
|
||||
guard visibleItems.indices.contains(index) else { return }
|
||||
selectItem(at: index)
|
||||
pasteSelectedPlainText()
|
||||
}
|
||||
|
||||
func copySelected() {
|
||||
if selectedItemCount > 1 {
|
||||
copySelectedItems()
|
||||
return
|
||||
}
|
||||
guard let item = selectedItem else { return }
|
||||
let result = pasteService.copy(item)
|
||||
if case .failed = result {} else {
|
||||
store.markUsed(item.id)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
statusMessage = result.message
|
||||
settings.setPasteStatus(message: result.message)
|
||||
performSelectedTransfer(.copy)
|
||||
}
|
||||
|
||||
func copySelectedPlainText() {
|
||||
if selectedItemCount > 1 {
|
||||
copySelectedItemsAsText()
|
||||
return
|
||||
}
|
||||
guard let item = selectedItem else { return }
|
||||
let result = pasteService.copyPlainText(item)
|
||||
if case .failed = result {} else {
|
||||
store.markUsed(item.id)
|
||||
selectedItemID = item.id
|
||||
}
|
||||
statusMessage = result.message
|
||||
settings.setPasteStatus(message: result.message)
|
||||
performSelectedTransfer(.copy, asPlainText: true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
func isItemStacked(at index: Int) -> Bool {
|
||||
guard index >= 0 && index < visibleItems.count else { return false }
|
||||
return stackItemIDSet.contains(visibleItems[index].id)
|
||||
@@ -830,11 +750,6 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func clearStackSelection() {
|
||||
guard isStackFilterSelected else { return }
|
||||
isStackFilterSelected = false
|
||||
}
|
||||
|
||||
func clearStack() {
|
||||
guard !stackItemIDs.isEmpty else {
|
||||
statusMessage = "Stack is empty"
|
||||
@@ -846,50 +761,22 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
|
||||
func copyNextStackItem() {
|
||||
guard let item = nextStackItem() else {
|
||||
statusMessage = "Stack is empty"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.copy(item)
|
||||
handleStackActionResult(result, item: item)
|
||||
performNextStackTransfer(.copy)
|
||||
}
|
||||
|
||||
func pasteNextStackItem() {
|
||||
guard let item = nextStackItem() else {
|
||||
statusMessage = "Stack is empty"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
|
||||
if case .pasted = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
handleStackActionResult(result, item: item)
|
||||
performNextStackTransfer(.paste)
|
||||
}
|
||||
|
||||
func copyStackAsText() {
|
||||
guard let package = stackPlainTextPackage() else {
|
||||
statusMessage = "Stack has no text to copy"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.copyPlainText(package.text)
|
||||
handleStackPlainTextActionResult(result, items: package.items)
|
||||
performStackTextTransfer(.copy)
|
||||
}
|
||||
|
||||
func pasteStackAsText() {
|
||||
guard let package = stackPlainTextPackage() else {
|
||||
statusMessage = "Stack has no text to paste"
|
||||
return
|
||||
performStackTextTransfer(.paste)
|
||||
}
|
||||
|
||||
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
|
||||
if case .pastedPlainText = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
handleStackPlainTextActionResult(result, items: package.items)
|
||||
}
|
||||
|
||||
|
||||
func addSelectedItemsToStack() {
|
||||
pruneStackItems()
|
||||
@@ -912,53 +799,15 @@ final class ClipboardPanelViewModel {
|
||||
statusMessage = "Added \(newIDs.count) selected \(noun) to Stack"
|
||||
}
|
||||
|
||||
func copySelectedItems() {
|
||||
let selectedItems = selectedItemsInSelectionOrder()
|
||||
guard selectedItems.count > 1 else {
|
||||
copySelected()
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.copy(selectedItems)
|
||||
handleSelectedActionResult(result, items: selectedItems)
|
||||
}
|
||||
|
||||
func pasteSelectedItems() {
|
||||
let selectedItems = selectedItemsInSelectionOrder()
|
||||
guard selectedItems.count > 1 else {
|
||||
pasteSelected()
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.paste(selectedItems, targetApp: targetApplicationProvider())
|
||||
if case .pasted = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
handleSelectedActionResult(result, items: selectedItems)
|
||||
}
|
||||
|
||||
func copySelectedItemsAsText() {
|
||||
guard let package = selectedPlainTextPackage() else {
|
||||
statusMessage = "Selection has no text to copy"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.copyPlainText(package.text)
|
||||
handleSelectedPlainTextActionResult(result, items: package.items)
|
||||
performSelectedTransfer(.copy, asPlainText: true, forceGroup: true)
|
||||
}
|
||||
|
||||
func pasteSelectedItemsAsText() {
|
||||
guard let package = selectedPlainTextPackage() else {
|
||||
statusMessage = "Selection has no text to paste"
|
||||
return
|
||||
performSelectedTransfer(.paste, asPlainText: true, forceGroup: true)
|
||||
}
|
||||
|
||||
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
|
||||
if case .pastedPlainText = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
handleSelectedPlainTextActionResult(result, items: package.items)
|
||||
}
|
||||
|
||||
|
||||
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
|
||||
guard index >= 0 && index < visibleItems.count else { return [] }
|
||||
@@ -970,13 +819,6 @@ final class ClipboardPanelViewModel {
|
||||
return item.payload
|
||||
}
|
||||
|
||||
func editableTextForItem(at index: Int) -> String? {
|
||||
guard index >= 0 && index < visibleItems.count else { return nil }
|
||||
let item = visibleItems[index]
|
||||
guard item.kind == .text || item.kind == .code else { return nil }
|
||||
return item.payload
|
||||
}
|
||||
|
||||
func editableTitleForSelected() -> String? {
|
||||
guard let item = selectedItem else { return nil }
|
||||
return item.customTitle ?? ""
|
||||
@@ -1540,10 +1382,6 @@ final class ClipboardPanelViewModel {
|
||||
statusMessage = "Deleted \(normalizedName)"
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
func showSelectedInClipboard() {
|
||||
guard canShowSelectedInClipboard, let item = selectedItem else { return }
|
||||
selectedItemID = item.id
|
||||
@@ -1762,85 +1600,139 @@ final class ClipboardPanelViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueSearchFacetValues(_ values: [String?]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for value in values {
|
||||
guard let normalized = value?.clipboardTrimmed, !normalized.isEmpty else { continue }
|
||||
let key = Self.normalizedSearchValue(normalized)
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
result.append(normalized)
|
||||
}
|
||||
return result.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func selectedItemsInSelectionOrder() -> [ClipboardItem] {
|
||||
let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] }
|
||||
if !selectedItems.isEmpty {
|
||||
return selectedItems
|
||||
}
|
||||
return selectedItem.map { [$0] } ?? []
|
||||
let items = selectedItemIDs.compactMap { visibleItemByID[$0] }
|
||||
return items.isEmpty ? selectedItem.map { [$0] } ?? [] : items
|
||||
}
|
||||
|
||||
private func nextStackItem() -> ClipboardItem? {
|
||||
pruneStackItems()
|
||||
guard let id = stackItemIDs.first else { return nil }
|
||||
return itemByID[id]
|
||||
return stackItemIDs.first.flatMap { itemByID[$0] }
|
||||
}
|
||||
|
||||
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
||||
pruneStackItems()
|
||||
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
|
||||
guard let item = itemByID[id],
|
||||
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
|
||||
private func plainTextPackage(for items: [ClipboardItem]) -> (text: String, items: [ClipboardItem])? {
|
||||
let pairs = items.compactMap { item -> (ClipboardItem, String)? in
|
||||
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed,
|
||||
!text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (item, text)
|
||||
}
|
||||
guard !pairs.isEmpty else { return nil }
|
||||
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
|
||||
return (pairs.map(\.1).joined(separator: "\n\n"), pairs.map(\.0))
|
||||
}
|
||||
|
||||
private func selectedPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
||||
let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in
|
||||
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (item, text)
|
||||
}
|
||||
guard !pairs.isEmpty else { return nil }
|
||||
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
|
||||
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
||||
pruneStackItems()
|
||||
return plainTextPackage(for: stackItemIDs.compactMap { itemByID[$0] })
|
||||
}
|
||||
|
||||
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
private func performSelectedTransfer(
|
||||
_ intent: TransferIntent,
|
||||
asPlainText: Bool = false,
|
||||
forceGroup: Bool = false
|
||||
) {
|
||||
let items = selectedItemsInSelectionOrder()
|
||||
let isGroup = forceGroup || items.count > 1
|
||||
|
||||
if asPlainText && isGroup {
|
||||
guard let package = plainTextPackage(for: items) else {
|
||||
statusMessage = "Selection has no text to \(intent == .copy ? "copy" : "paste")"
|
||||
return
|
||||
}
|
||||
completeTransfer(
|
||||
plainTextTransfer(intent, value: package.text),
|
||||
items: package.items,
|
||||
context: .selected
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let item = items.first else { return }
|
||||
let result = asPlainText
|
||||
? plainTextTransfer(intent, item: item)
|
||||
: originalTransfer(intent, items: items)
|
||||
completeTransfer(result, items: items, context: isGroup ? .selected : .single)
|
||||
}
|
||||
|
||||
private func performNextStackTransfer(_ intent: TransferIntent) {
|
||||
guard let item = nextStackItem() else {
|
||||
statusMessage = "Stack is empty"
|
||||
return
|
||||
}
|
||||
completeTransfer(originalTransfer(intent, items: [item]), items: [item], context: .stackItem)
|
||||
}
|
||||
|
||||
private func performStackTextTransfer(_ intent: TransferIntent) {
|
||||
guard let package = stackPlainTextPackage() else {
|
||||
statusMessage = "Stack has no text to \(intent == .copy ? "copy" : "paste")"
|
||||
return
|
||||
}
|
||||
completeTransfer(
|
||||
plainTextTransfer(intent, value: package.text),
|
||||
items: package.items,
|
||||
context: .stackText
|
||||
)
|
||||
}
|
||||
|
||||
private func originalTransfer(
|
||||
_ intent: TransferIntent,
|
||||
items: [ClipboardItem]
|
||||
) -> PasteActionService.PasteActionResult {
|
||||
if let item = items.first, items.count == 1 {
|
||||
return intent == .copy
|
||||
? pasteService.copy(item)
|
||||
: pasteService.paste(item, targetApp: targetApplicationProvider())
|
||||
}
|
||||
return intent == .copy
|
||||
? pasteService.copy(items)
|
||||
: pasteService.paste(items, targetApp: targetApplicationProvider())
|
||||
}
|
||||
|
||||
private func plainTextTransfer(
|
||||
_ intent: TransferIntent,
|
||||
item: ClipboardItem
|
||||
) -> PasteActionService.PasteActionResult {
|
||||
intent == .copy
|
||||
? pasteService.copyPlainText(item)
|
||||
: pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
|
||||
}
|
||||
|
||||
private func plainTextTransfer(
|
||||
_ intent: TransferIntent,
|
||||
value: String
|
||||
) -> PasteActionService.PasteActionResult {
|
||||
intent == .copy
|
||||
? pasteService.copyPlainText(value)
|
||||
: pasteService.pastePlainText(value, targetApp: targetApplicationProvider())
|
||||
}
|
||||
|
||||
private func completeTransfer(
|
||||
_ result: PasteActionService.PasteActionResult,
|
||||
items: [ClipboardItem],
|
||||
context: TransferContext
|
||||
) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
if case .single = context {
|
||||
settings.setPasteStatus(message: message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if case .pasted = result {
|
||||
willPasteToTarget()
|
||||
} else if case .pastedPlainText = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
|
||||
switch context {
|
||||
case .stackItem:
|
||||
if let item = items.first {
|
||||
consumeStackItem(item.id)
|
||||
store.markUsed(item.id)
|
||||
selectedItemID = item.id
|
||||
switch result {
|
||||
case .copiedNeedsPermission:
|
||||
statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically."
|
||||
case .pasted:
|
||||
statusMessage = "Pasted from Stack"
|
||||
case .copied:
|
||||
statusMessage = "Copied from Stack"
|
||||
default:
|
||||
statusMessage = result.message
|
||||
}
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
case .stackText:
|
||||
for item in items {
|
||||
store.markUsed(item.id)
|
||||
consumeStackItem(item.id, refreshActiveStackFilter: false)
|
||||
@@ -1850,68 +1742,56 @@ final class ClipboardPanelViewModel {
|
||||
} else if isStackFilterSelected {
|
||||
recomputeVisibleItems()
|
||||
}
|
||||
selectedItemID = items.first?.id
|
||||
let noun = items.count == 1 ? "clip" : "clips"
|
||||
switch result {
|
||||
case .pastedPlainText:
|
||||
statusMessage = "Pasted \(items.count) Stack \(noun) as Text"
|
||||
case .copiedPlainTextNeedsPermission:
|
||||
statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
|
||||
case .copiedPlainText:
|
||||
statusMessage = "Copied \(items.count) Stack \(noun) as Text"
|
||||
default:
|
||||
statusMessage = result.message
|
||||
}
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
case .single, .selected:
|
||||
for item in items {
|
||||
store.markUsed(item.id)
|
||||
}
|
||||
selectedItemID = items.first?.id
|
||||
let noun = items.count == 1 ? "clip" : "clips"
|
||||
switch result {
|
||||
case .pasted:
|
||||
statusMessage = "Pasted \(items.count) selected \(noun)"
|
||||
case .copiedNeedsPermission:
|
||||
statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically."
|
||||
case .copied:
|
||||
statusMessage = "Copied \(items.count) selected \(noun)"
|
||||
default:
|
||||
statusMessage = result.message
|
||||
}
|
||||
|
||||
selectedItemID = items.first?.id
|
||||
statusMessage = transferStatus(result, count: items.count, context: context)
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
return
|
||||
private func transferStatus(
|
||||
_ result: PasteActionService.PasteActionResult,
|
||||
count: Int,
|
||||
context: TransferContext
|
||||
) -> String {
|
||||
let noun = count == 1 ? "clip" : "clips"
|
||||
switch (context, result) {
|
||||
case (.single, _):
|
||||
return result.message
|
||||
case (.stackItem, .pasted):
|
||||
return "Pasted from Stack"
|
||||
case (.stackItem, .copied):
|
||||
return "Copied from Stack"
|
||||
case (.stackItem, .copiedNeedsPermission):
|
||||
return "Copied from Stack. Grant Accessibility access to paste automatically."
|
||||
case (.selected, .pasted):
|
||||
return "Pasted \(count) selected \(noun)"
|
||||
case (.selected, .copied):
|
||||
return "Copied \(count) selected \(noun)"
|
||||
case (.selected, .copiedNeedsPermission):
|
||||
return "Copied \(count) selected \(noun). Grant Accessibility access to paste automatically."
|
||||
case (.selected, .pastedPlainText):
|
||||
return "Pasted \(count) selected \(noun) as Text"
|
||||
case (.selected, .copiedPlainText):
|
||||
return "Copied \(count) selected \(noun) as Text"
|
||||
case (.selected, .copiedPlainTextNeedsPermission):
|
||||
return "Copied \(count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
|
||||
case (.stackText, .pastedPlainText):
|
||||
return "Pasted \(count) Stack \(noun) as Text"
|
||||
case (.stackText, .copiedPlainText):
|
||||
return "Copied \(count) Stack \(noun) as Text"
|
||||
case (.stackText, .copiedPlainTextNeedsPermission):
|
||||
return "Copied \(count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
|
||||
default:
|
||||
return result.message
|
||||
}
|
||||
}
|
||||
|
||||
for item in items {
|
||||
store.markUsed(item.id)
|
||||
}
|
||||
selectedItemID = items.first?.id
|
||||
let noun = items.count == 1 ? "clip" : "clips"
|
||||
switch result {
|
||||
case .pastedPlainText:
|
||||
statusMessage = "Pasted \(items.count) selected \(noun) as Text"
|
||||
case .copiedPlainTextNeedsPermission:
|
||||
statusMessage = "Copied \(items.count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
|
||||
case .copiedPlainText:
|
||||
statusMessage = "Copied \(items.count) selected \(noun) as Text"
|
||||
default:
|
||||
statusMessage = result.message
|
||||
}
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
|
||||
private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) {
|
||||
guard let index = stackItemIDs.firstIndex(of: id) else { return }
|
||||
@@ -1925,9 +1805,6 @@ final class ClipboardPanelViewModel {
|
||||
guard stackItemsNeedPruning else { return }
|
||||
stackItemsNeedPruning = false
|
||||
guard !stackItemIDs.isEmpty else { return }
|
||||
#if DEBUG
|
||||
debugStackPruneScanCount += 1
|
||||
#endif
|
||||
let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
|
||||
if pruned != stackItemIDs {
|
||||
stackItemIDs = pruned
|
||||
@@ -1960,9 +1837,6 @@ final class ClipboardPanelViewModel {
|
||||
|
||||
private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] {
|
||||
if let searchMatchCache, searchMatchCache.query == query {
|
||||
#if DEBUG
|
||||
debugSearchMatchCacheHitCount += 1
|
||||
#endif
|
||||
return searchMatchCache.indexedItems
|
||||
}
|
||||
|
||||
@@ -1975,9 +1849,6 @@ final class ClipboardPanelViewModel {
|
||||
var matches: [IndexedClipboardItem] = []
|
||||
matches.reserveCapacity(allIndexedItems.count)
|
||||
for indexedItem in allIndexedItems {
|
||||
#if DEBUG
|
||||
debugSearchItemEvaluationCount += 1
|
||||
#endif
|
||||
if matchesSearchQuery(indexedItem.item, query: parsedQuery) {
|
||||
matches.append(indexedItem)
|
||||
}
|
||||
@@ -2019,9 +1890,6 @@ final class ClipboardPanelViewModel {
|
||||
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
|
||||
)
|
||||
computed = indexedItems.map(\.item)
|
||||
#if DEBUG
|
||||
debugVisibleItemsIndexedLookupCount += 1
|
||||
#endif
|
||||
} else {
|
||||
computed = filterAndSortVisibleItems(
|
||||
indexedItemsMatchingSearch(query),
|
||||
@@ -2029,9 +1897,6 @@ final class ClipboardPanelViewModel {
|
||||
collectionName: collectionName,
|
||||
categoryFilters: categoryFilters
|
||||
)
|
||||
#if DEBUG
|
||||
debugVisibleItemsFullScanCount += 1
|
||||
#endif
|
||||
}
|
||||
if visibleItemsCache.count > 24 {
|
||||
visibleItemsCache.removeAll(keepingCapacity: true)
|
||||
@@ -2201,9 +2066,6 @@ final class ClipboardPanelViewModel {
|
||||
)
|
||||
}
|
||||
categoryFilterSelectionCache = selection
|
||||
#if DEBUG
|
||||
debugCategoryFilterSelectionBuildCount += 1
|
||||
#endif
|
||||
return selection
|
||||
}
|
||||
|
||||
@@ -2344,9 +2206,6 @@ final class ClipboardPanelViewModel {
|
||||
ocrText: item.ocrText
|
||||
)
|
||||
if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint {
|
||||
#if DEBUG
|
||||
debugSearchDocumentCacheHitCount += 1
|
||||
#endif
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -2368,9 +2227,6 @@ final class ClipboardPanelViewModel {
|
||||
collection: item.collectionName.map(normalizedStructuredValue) ?? ""
|
||||
)
|
||||
searchDocumentsByItemID[item.id] = document
|
||||
#if DEBUG
|
||||
debugSearchDocumentBuildCount += 1
|
||||
#endif
|
||||
return document
|
||||
}
|
||||
|
||||
|
||||
@@ -352,53 +352,4 @@ final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegat
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugTitleText: String {
|
||||
titleLabel.stringValue
|
||||
}
|
||||
|
||||
var debugAddressText: String {
|
||||
addressLabel.stringValue
|
||||
}
|
||||
|
||||
var debugStatusText: String {
|
||||
statusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugTitleTooltip: String? {
|
||||
titleLabel.toolTip
|
||||
}
|
||||
|
||||
var debugAddressTooltip: String? {
|
||||
addressLabel.toolTip
|
||||
}
|
||||
|
||||
var debugStatusTooltip: String? {
|
||||
statusLabel.toolTip
|
||||
}
|
||||
|
||||
func debugPrepareForPreview(_ request: LinkPreviewRequest) {
|
||||
prepareForPreview(request)
|
||||
}
|
||||
|
||||
func debugSetDisplayedPageURL(_ url: URL) {
|
||||
setAddress(url)
|
||||
}
|
||||
|
||||
func debugOpenInBrowser() {
|
||||
openInBrowser()
|
||||
}
|
||||
|
||||
func debugAllowObservedPageTitles() {
|
||||
acceptsObservedPageTitles = true
|
||||
}
|
||||
|
||||
func debugApplyObservedPageTitle(_ title: String?) {
|
||||
applyObservedPageTitle(title)
|
||||
}
|
||||
|
||||
func debugApplyNavigationFailure(_ error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -355,13 +355,4 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
||||
settings.iCloudSyncEnabled = iCloudSyncButton.state == .on
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugShowMenuBarIconIsEnabled: Bool {
|
||||
showMenuBarIconButton.state == .on
|
||||
}
|
||||
|
||||
var debugShowDockIconIsEnabled: Bool {
|
||||
showDockIconButton.state == .on
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
static let settingsContentMinimumWidth: CGFloat = 440
|
||||
static let settingsLabelWidth: CGFloat = 128
|
||||
}
|
||||
private enum ControlCommand: Int {
|
||||
case historyLength, historyRetention, pruneDuplicates, keepFirstImage, defaultSort
|
||||
case launchAtLogin, showMenuBarIcon, showDockIcon, panelSide
|
||||
case pauseCapture, excludeSensitive, includeImageText
|
||||
case clearHistoryOnQuit, hideFromScreenCapture, requestAccessibility, refreshAccessibility
|
||||
case pollProfile, cacheLimit
|
||||
case iCloudSync, pushICloudArchive, pullICloudArchive, revealICloudFile
|
||||
case openHistoryFolder, exportArchive, importArchive, clearHistory, clearCache
|
||||
}
|
||||
private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]
|
||||
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
|
||||
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
|
||||
@@ -82,11 +91,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
private let exportArchiveButton = NSButton()
|
||||
private let importArchiveButton = NSButton()
|
||||
|
||||
#if DEBUG
|
||||
private var debugFullRefreshCountValue = 0
|
||||
private var debugIgnoredAppsRefreshCountValue = 0
|
||||
private var debugDestructiveActionConfirmationOverride: Bool?
|
||||
#endif
|
||||
|
||||
init(
|
||||
settings: SettingsModel,
|
||||
@@ -180,10 +184,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
return item
|
||||
}
|
||||
|
||||
private func tabTitle(for item: NSTabViewItem) -> String {
|
||||
item.label.clipboardTrimmed
|
||||
}
|
||||
|
||||
private func scrollContainer(for content: NSView) -> NSView {
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.hasVerticalScroller = true
|
||||
@@ -216,26 +216,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
|
||||
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
|
||||
historyStepper.increment = 25
|
||||
historyStepper.target = self
|
||||
historyStepper.action = #selector(historyLengthChanged)
|
||||
bind(historyStepper, to: .historyLength)
|
||||
historyStepper.setAccessibilityLabel("History length")
|
||||
configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged))
|
||||
configurePopup(historyRetentionPopup, command: .historyRetention)
|
||||
historyRetentionPopup.setAccessibilityLabel("Keep history")
|
||||
for retention in HistoryRetention.allCases {
|
||||
addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup)
|
||||
}
|
||||
|
||||
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged))
|
||||
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged))
|
||||
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged))
|
||||
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", command: .pruneDuplicates)
|
||||
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", command: .keepFirstImage)
|
||||
configurePopup(defaultSortPopup, command: .defaultSort)
|
||||
defaultSortPopup.setAccessibilityLabel("Default sort")
|
||||
for mode in ClipboardSortMode.allCases {
|
||||
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
|
||||
}
|
||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
|
||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
|
||||
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged))
|
||||
configurePopup(panelSidePopup, action: #selector(panelSideChanged))
|
||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", command: .launchAtLogin)
|
||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", command: .showMenuBarIcon)
|
||||
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", command: .showDockIcon)
|
||||
configurePopup(panelSidePopup, command: .panelSide)
|
||||
panelSidePopup.setAccessibilityLabel("Shelf side")
|
||||
for side in ClipboardPanelSide.allCases {
|
||||
addPopupItem(side.title, side.rawValue, to: panelSidePopup)
|
||||
@@ -278,9 +277,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
|
||||
private func captureSettingsView() -> NSView {
|
||||
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged))
|
||||
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged))
|
||||
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged))
|
||||
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", command: .pauseCapture)
|
||||
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", command: .excludeSensitive)
|
||||
configureCheckbox(includeImageTextButton, title: "Search in image labels", command: .includeImageText)
|
||||
configureStatusLabel(captureStatusLabel)
|
||||
|
||||
let allowedRows = [
|
||||
@@ -333,11 +332,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
let storageLabel = caption("History stays in Application Support. Text and managed media are encrypted with Keychain or an owner-only fallback key.")
|
||||
let screenPrivacyLabel = caption("When enabled, the clipboard panel is hidden from screenshots, screen sharing, and screen recordings.")
|
||||
let permissionHelpLabel = caption("Clipboard capture works without this permission. Grant Accessibility only for direct paste.")
|
||||
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
|
||||
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", action: #selector(hideFromScreenCaptureChanged))
|
||||
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", command: .clearHistoryOnQuit)
|
||||
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", command: .hideFromScreenCapture)
|
||||
configureStatusLabel(accessibilityStatusLabel)
|
||||
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess))
|
||||
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus))
|
||||
let requestButton = button("Open Accessibility Settings", .requestAccessibility)
|
||||
let refreshButton = button("Refresh Permission Status", .refreshAccessibility)
|
||||
configureStatusLabel(pasteStatusLabel)
|
||||
|
||||
return page([
|
||||
@@ -361,7 +360,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
|
||||
private func performanceSettingsView() -> NSView {
|
||||
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged))
|
||||
configurePopup(pollProfilePopup, command: .pollProfile)
|
||||
pollProfilePopup.setAccessibilityLabel("Polling profile")
|
||||
for profile in AppConfiguration.PollProfile.allCases {
|
||||
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
|
||||
@@ -370,8 +369,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024
|
||||
cacheSlider.numberOfTickMarks = 9
|
||||
cacheSlider.allowsTickMarkValuesOnly = true
|
||||
cacheSlider.target = self
|
||||
cacheSlider.action = #selector(cacheLimitChanged)
|
||||
bind(cacheSlider, to: .cacheLimit)
|
||||
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
|
||||
configureStatusLabel(cacheLabel)
|
||||
|
||||
@@ -389,12 +387,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
private func dataSettingsView() -> NSView {
|
||||
configureStatusLabel(dataStatusLabel)
|
||||
configureStatusLabel(cloudSyncStatusLabel)
|
||||
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", action: #selector(iCloudSyncChanged))
|
||||
configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive))
|
||||
configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive))
|
||||
configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile))
|
||||
configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive))
|
||||
configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive))
|
||||
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", command: .iCloudSync)
|
||||
configureButton(iCloudSyncNowButton, title: "Sync Now", command: .pushICloudArchive)
|
||||
configureButton(iCloudRestoreButton, title: "Restore from iCloud", command: .pullICloudArchive)
|
||||
configureButton(iCloudRevealButton, title: "Reveal Sync File", command: .revealICloudFile)
|
||||
configureButton(exportArchiveButton, title: "Export Archive...", command: .exportArchive)
|
||||
configureButton(importArchiveButton, title: "Import Archive...", command: .importArchive)
|
||||
let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.")
|
||||
let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.")
|
||||
return page([
|
||||
@@ -416,9 +414,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
])
|
||||
]),
|
||||
section("Data", [
|
||||
button("Open History Folder", #selector(openHistoryFolder)),
|
||||
button("Clear Clipboard History", #selector(clearClipboardHistory)),
|
||||
button("Clear Thumbnail Cache", #selector(clearThumbnailCache)),
|
||||
button("Open History Folder", .openHistoryFolder),
|
||||
button("Clear Clipboard History", .clearHistory),
|
||||
button("Clear Thumbnail Cache", .clearCache),
|
||||
dataStatusLabel
|
||||
])
|
||||
])
|
||||
@@ -472,20 +470,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
return label
|
||||
}
|
||||
|
||||
private func button(_ title: String, _ action: Selector) -> NSButton {
|
||||
private func button(_ title: String, _ command: ControlCommand) -> NSButton {
|
||||
let control = NSButton()
|
||||
configureButton(control, title: title, action: action)
|
||||
configureButton(control, title: title, command: command)
|
||||
return control
|
||||
}
|
||||
|
||||
private func configureButton(_ control: NSButton, title: String, action: Selector) {
|
||||
private func configureButton(_ control: NSButton, title: String, command: ControlCommand) {
|
||||
control.title = title
|
||||
control.target = self
|
||||
control.action = action
|
||||
bind(control, to: command)
|
||||
control.bezelStyle = .rounded
|
||||
control.setAccessibilityLabel(title)
|
||||
}
|
||||
|
||||
private func configureCheckbox(_ control: NSButton, title: String, command: ControlCommand) {
|
||||
configureCheckbox(control, title: title, action: #selector(performControlCommand(_:)))
|
||||
control.tag = command.rawValue
|
||||
}
|
||||
|
||||
private func configureCheckbox(_ control: NSButton, title: String, action: Selector) {
|
||||
control.setButtonType(.switch)
|
||||
control.title = title
|
||||
@@ -504,10 +506,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
}
|
||||
|
||||
private func configurePopup(_ popup: NSPopUpButton, action: Selector) {
|
||||
private func configurePopup(_ popup: NSPopUpButton, command: ControlCommand) {
|
||||
popup.removeAllItems()
|
||||
popup.target = self
|
||||
popup.action = action
|
||||
bind(popup, to: command)
|
||||
}
|
||||
|
||||
private func bind(_ control: NSControl, to command: ControlCommand) {
|
||||
control.tag = command.rawValue
|
||||
control.target = self
|
||||
control.action = #selector(performControlCommand(_:))
|
||||
}
|
||||
|
||||
private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) {
|
||||
@@ -628,9 +635,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
|
||||
private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) {
|
||||
#if DEBUG
|
||||
debugFullRefreshCountValue += 1
|
||||
#endif
|
||||
|
||||
refreshHistoryLimitControls()
|
||||
refreshHistoryRetentionControl()
|
||||
@@ -969,9 +973,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
|
||||
private func refreshIgnoredAppsTextView(force: Bool = false) {
|
||||
#if DEBUG
|
||||
debugIgnoredAppsRefreshCountValue += 1
|
||||
#endif
|
||||
|
||||
guard force || !isEditingIgnoredApps else { return }
|
||||
|
||||
@@ -1048,34 +1049,27 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func historyLengthChanged() {
|
||||
@objc private func performControlCommand(_ sender: NSControl) {
|
||||
guard let command = ControlCommand(rawValue: sender.tag) else { return }
|
||||
switch command {
|
||||
case .historyLength:
|
||||
settings.maxHistoryItems = historyStepper.integerValue
|
||||
refreshHistoryLimitControls()
|
||||
}
|
||||
|
||||
@objc private func historyRetentionChanged() {
|
||||
case .historyRetention:
|
||||
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
|
||||
let retention = HistoryRetention(rawValue: rawValue) {
|
||||
settings.historyRetention = retention
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pruneDuplicatesChanged() {
|
||||
case .pruneDuplicates:
|
||||
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func keepFirstImageChanged() {
|
||||
case .keepFirstImage:
|
||||
settings.keepFirstImage = keepFirstImageButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func defaultSortChanged() {
|
||||
case .defaultSort:
|
||||
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
|
||||
let mode = ClipboardSortMode(rawValue: rawValue) {
|
||||
settings.defaultSortMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func launchAtLoginChanged() {
|
||||
case .launchAtLogin:
|
||||
let enabled = launchAtLoginButton.state == .on
|
||||
settings.launchAtLogin = enabled
|
||||
if !enabled {
|
||||
@@ -1085,31 +1079,69 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshLaunchAtLoginControls()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showMenuBarIconChanged() {
|
||||
let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on
|
||||
settings.showMenuBarIcon = shouldShowMenuBarIcon
|
||||
if !shouldShowMenuBarIcon && !settings.showDockIcon {
|
||||
case .showMenuBarIcon:
|
||||
let shouldShow = showMenuBarIconButton.state == .on
|
||||
settings.showMenuBarIcon = shouldShow
|
||||
if !shouldShow && !settings.showDockIcon {
|
||||
settings.showDockIcon = true
|
||||
}
|
||||
refreshVisibilityControls()
|
||||
}
|
||||
|
||||
@objc private func showDockIconChanged() {
|
||||
let shouldShowDockIcon = showDockIconButton.state == .on
|
||||
settings.showDockIcon = shouldShowDockIcon
|
||||
if !shouldShowDockIcon && !settings.showMenuBarIcon {
|
||||
case .showDockIcon:
|
||||
let shouldShow = showDockIconButton.state == .on
|
||||
settings.showDockIcon = shouldShow
|
||||
if !shouldShow && !settings.showMenuBarIcon {
|
||||
settings.showMenuBarIcon = true
|
||||
}
|
||||
refreshVisibilityControls()
|
||||
}
|
||||
|
||||
@objc private func panelSideChanged() {
|
||||
case .panelSide:
|
||||
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
|
||||
let side = ClipboardPanelSide(rawValue: rawValue) {
|
||||
settings.panelSide = side
|
||||
}
|
||||
case .pauseCapture:
|
||||
if pauseCaptureButton.state == .on {
|
||||
settings.pauseCaptureUntil = nil
|
||||
settings.pauseCapture = true
|
||||
} else {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
}
|
||||
case .excludeSensitive:
|
||||
settings.excludeSensitive = excludeSensitiveButton.state == .on
|
||||
case .includeImageText:
|
||||
settings.includeImageTextInSearch = includeImageTextButton.state == .on
|
||||
case .clearHistoryOnQuit:
|
||||
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
|
||||
case .hideFromScreenCapture:
|
||||
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
|
||||
case .requestAccessibility: requestAccessibilityAccess()
|
||||
case .refreshAccessibility: refreshAccessibilityPermissionStatus()
|
||||
case .pollProfile:
|
||||
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
|
||||
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
|
||||
settings.pollProfileRaw = profile
|
||||
}
|
||||
case .cacheLimit:
|
||||
let megabytes = Int(cacheSlider.doubleValue.rounded())
|
||||
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
|
||||
refreshCacheControls()
|
||||
case .iCloudSync:
|
||||
let enabled = iCloudSyncButton.state == .on
|
||||
settings.iCloudSyncEnabled = enabled
|
||||
settings.setCloudSyncStatus(message: "")
|
||||
if !enabled {
|
||||
cachedCloudSyncStatus = nil
|
||||
}
|
||||
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
|
||||
case .openHistoryFolder: openHistoryFolder()
|
||||
case .exportArchive: exportClipboardArchive()
|
||||
case .importArchive: importClipboardArchive()
|
||||
case .pushICloudArchive: pushICloudSyncArchive()
|
||||
case .pullICloudArchive: pullICloudSyncArchive()
|
||||
case .revealICloudFile: revealICloudSyncFile()
|
||||
case .clearHistory: clearClipboardHistory()
|
||||
case .clearCache: clearThumbnailCache()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func shortcutChanged(_ sender: NSControl) {
|
||||
@@ -1171,24 +1203,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pauseCaptureChanged() {
|
||||
if pauseCaptureButton.state == .on {
|
||||
settings.pauseCaptureUntil = nil
|
||||
settings.pauseCapture = true
|
||||
} else {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func excludeSensitiveChanged() {
|
||||
settings.excludeSensitive = excludeSensitiveButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func includeImageTextChanged() {
|
||||
settings.includeImageTextInSearch = includeImageTextButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func allowedKindChanged(_ sender: NSButton) {
|
||||
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
|
||||
var ignored = settings.ignoredItemKindsRaw
|
||||
@@ -1228,15 +1242,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
commitIgnoredAppsDraftIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func clearHistoryOnQuitChanged() {
|
||||
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func hideFromScreenCaptureChanged() {
|
||||
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
|
||||
}
|
||||
|
||||
@objc private func requestAccessibilityAccess() {
|
||||
private func requestAccessibilityAccess() {
|
||||
_ = AccessibilityPermissionService.requestPromptIfNeeded()
|
||||
if !AccessibilityPermissionService.isTrusted {
|
||||
AccessibilityPermissionService.openSystemSettings()
|
||||
@@ -1247,7 +1253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
refreshAccessibilityPermissionStatus()
|
||||
}
|
||||
|
||||
@objc private func refreshAccessibilityPermissionStatus() {
|
||||
private func refreshAccessibilityPermissionStatus() {
|
||||
settings.setAccessibilityPermissionStatus(
|
||||
message: AccessibilityPermissionService.isTrusted
|
||||
? ""
|
||||
@@ -1256,34 +1262,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
refreshAccessibilityPermissionStatusLabel()
|
||||
}
|
||||
|
||||
@objc private func pollProfileChanged() {
|
||||
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
|
||||
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
|
||||
settings.pollProfileRaw = profile
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cacheLimitChanged() {
|
||||
let megabytes = Int(cacheSlider.doubleValue.rounded())
|
||||
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
|
||||
refreshCacheControls()
|
||||
}
|
||||
|
||||
@objc private func iCloudSyncChanged() {
|
||||
let enabled = iCloudSyncButton.state == .on
|
||||
settings.iCloudSyncEnabled = enabled
|
||||
settings.setCloudSyncStatus(message: "")
|
||||
if !enabled {
|
||||
cachedCloudSyncStatus = nil
|
||||
}
|
||||
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
|
||||
}
|
||||
|
||||
@objc private func openHistoryFolder() {
|
||||
private func openHistoryFolder() {
|
||||
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
|
||||
}
|
||||
|
||||
@objc private func exportClipboardArchive() {
|
||||
private func exportClipboardArchive() {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export ClipBored Archive"
|
||||
panel.nameFieldStringValue = defaultArchiveFileName()
|
||||
@@ -1303,7 +1286,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func importClipboardArchive() {
|
||||
private func importClipboardArchive() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Import ClipBored Archive"
|
||||
panel.canChooseFiles = true
|
||||
@@ -1329,7 +1312,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pushICloudSyncArchive() {
|
||||
private func pushICloudSyncArchive() {
|
||||
guard settings.iCloudSyncEnabled else {
|
||||
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.")
|
||||
return
|
||||
@@ -1352,7 +1335,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pullICloudSyncArchive() {
|
||||
private func pullICloudSyncArchive() {
|
||||
guard settings.iCloudSyncEnabled else {
|
||||
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.")
|
||||
return
|
||||
@@ -1400,7 +1383,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func revealICloudSyncFile() {
|
||||
private func revealICloudSyncFile() {
|
||||
do {
|
||||
let url = try cloudSyncService.syncArchiveURL()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
@@ -1415,7 +1398,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func clearClipboardHistory() {
|
||||
private func clearClipboardHistory() {
|
||||
guard confirmDestructiveAction(
|
||||
title: "Clear Clipboard History?",
|
||||
message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.",
|
||||
@@ -1426,7 +1409,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
setDataStatus("Cleared clipboard history.")
|
||||
}
|
||||
|
||||
@objc private func clearThumbnailCache() {
|
||||
private func clearThumbnailCache() {
|
||||
guard confirmDestructiveAction(
|
||||
title: "Clear Thumbnail Cache?",
|
||||
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
|
||||
@@ -1437,11 +1420,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
}
|
||||
|
||||
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
|
||||
#if DEBUG
|
||||
if let override = debugDestructiveActionConfirmationOverride {
|
||||
return override
|
||||
}
|
||||
#endif
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
@@ -1551,447 +1529,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugWindowStyleMask: NSWindow.StyleMask {
|
||||
window?.styleMask ?? []
|
||||
}
|
||||
|
||||
var debugWindowMinSize: NSSize {
|
||||
window?.minSize ?? .zero
|
||||
}
|
||||
|
||||
var debugWindowContentSize: NSSize {
|
||||
window?.contentView?.bounds.size ?? .zero
|
||||
}
|
||||
|
||||
var debugRawSettingsTabLabels: [String] {
|
||||
tabView.tabViewItems.map(\.label)
|
||||
}
|
||||
|
||||
func debugSetWindowContentSize(_ size: NSSize) {
|
||||
window?.setContentSize(size)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
var debugSettingsTabLayoutMetrics: [(label: String, viewport: NSSize, document: NSSize, hasHorizontalScroller: Bool)] {
|
||||
let originalSelection = tabView.selectedTabViewItem
|
||||
defer {
|
||||
if let originalSelection {
|
||||
tabView.selectTabViewItem(originalSelection)
|
||||
}
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
return tabView.tabViewItems.map { item in
|
||||
tabView.selectTabViewItem(item)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
item.view?.layoutSubtreeIfNeeded()
|
||||
guard let scrollView = item.view as? NSScrollView else {
|
||||
return (tabTitle(for: item), .zero, .zero, true)
|
||||
}
|
||||
scrollView.documentView?.layoutSubtreeIfNeeded()
|
||||
return (
|
||||
tabTitle(for: item),
|
||||
scrollView.contentView.bounds.size,
|
||||
scrollView.documentView?.frame.size ?? .zero,
|
||||
scrollView.hasHorizontalScroller
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var debugSettingsTabLayoutAuditMetrics: [(label: String, overflowingViewCount: Int, zeroSizedControlCount: Int)] {
|
||||
let originalSelection = tabView.selectedTabViewItem
|
||||
defer {
|
||||
if let originalSelection {
|
||||
tabView.selectTabViewItem(originalSelection)
|
||||
}
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func visibleDescendants(of root: NSView) -> [NSView] {
|
||||
var result: [NSView] = []
|
||||
func visit(_ view: NSView) {
|
||||
guard !view.isHidden else { return }
|
||||
result.append(view)
|
||||
for subview in view.subviews {
|
||||
visit(subview)
|
||||
}
|
||||
}
|
||||
for subview in root.subviews {
|
||||
visit(subview)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldAudit(_ view: NSView) -> Bool {
|
||||
!(view is NSScroller)
|
||||
}
|
||||
|
||||
func canCollapseToZero(_ view: NSView) -> Bool {
|
||||
view is NSControl || view is NSTextView
|
||||
}
|
||||
|
||||
return tabView.tabViewItems.map { item in
|
||||
tabView.selectTabViewItem(item)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
item.view?.layoutSubtreeIfNeeded()
|
||||
guard let scrollView = item.view as? NSScrollView,
|
||||
let documentView = scrollView.documentView else {
|
||||
return (tabTitle(for: item), 1, 1)
|
||||
}
|
||||
|
||||
documentView.layoutSubtreeIfNeeded()
|
||||
let auditedViews = visibleDescendants(of: documentView).filter(shouldAudit)
|
||||
let documentBounds = documentView.bounds
|
||||
let overflowing = auditedViews.filter { view in
|
||||
let frame = view.convert(view.bounds, to: documentView)
|
||||
return frame.minX < -1 || frame.maxX > documentBounds.width + 1
|
||||
}
|
||||
let zeroSizedControls = auditedViews.filter { view in
|
||||
guard canCollapseToZero(view) else { return false }
|
||||
let frame = view.convert(view.bounds, to: documentView)
|
||||
return frame.width <= 0.5 || frame.height <= 0.5
|
||||
}
|
||||
return (tabTitle(for: item), overflowing.count, zeroSizedControls.count)
|
||||
}
|
||||
}
|
||||
|
||||
var debugSettingsTabContentPlacementMetrics: [(label: String, contentBounds: NSRect, document: NSSize)] {
|
||||
let originalSelection = tabView.selectedTabViewItem
|
||||
defer {
|
||||
if let originalSelection {
|
||||
tabView.selectTabViewItem(originalSelection)
|
||||
}
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func visibleControlsAndLabels(of root: NSView) -> [NSView] {
|
||||
var result: [NSView] = []
|
||||
func visit(_ view: NSView) {
|
||||
guard !view.isHidden else { return }
|
||||
if view is NSControl || view is NSTextView {
|
||||
result.append(view)
|
||||
}
|
||||
for subview in view.subviews {
|
||||
visit(subview)
|
||||
}
|
||||
}
|
||||
for subview in root.subviews {
|
||||
visit(subview)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return tabView.tabViewItems.map { item in
|
||||
tabView.selectTabViewItem(item)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
item.view?.layoutSubtreeIfNeeded()
|
||||
guard let scrollView = item.view as? NSScrollView,
|
||||
let documentView = scrollView.documentView else {
|
||||
return (tabTitle(for: item), .zero, .zero)
|
||||
}
|
||||
|
||||
documentView.layoutSubtreeIfNeeded()
|
||||
let bounds = visibleControlsAndLabels(of: documentView)
|
||||
.map { $0.convert($0.bounds, to: documentView) }
|
||||
.reduce(NSRect.null) { $0.union($1) }
|
||||
return (tabTitle(for: item), bounds.isNull ? .zero : bounds, documentView.frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
var debugCloudSyncStatusText: String {
|
||||
cloudSyncStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugCloudSyncActionButtonsAreEnabled: [Bool] {
|
||||
[
|
||||
iCloudSyncNowButton.isEnabled,
|
||||
iCloudRestoreButton.isEnabled,
|
||||
iCloudRevealButton.isEnabled
|
||||
]
|
||||
}
|
||||
|
||||
var debugHistoryText: String {
|
||||
historyLabel.stringValue
|
||||
}
|
||||
|
||||
var debugHistoryStepperValue: Int {
|
||||
historyStepper.integerValue
|
||||
}
|
||||
|
||||
var debugCacheStatusText: String {
|
||||
cacheLabel.stringValue
|
||||
}
|
||||
|
||||
var debugCacheSliderMegabytes: Int {
|
||||
Int(cacheSlider.doubleValue.rounded())
|
||||
}
|
||||
|
||||
var debugDataStatusText: String {
|
||||
dataStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugDataStatusColor: NSColor {
|
||||
dataStatusLabel.textColor ?? .clear
|
||||
}
|
||||
|
||||
var debugDataStatusSectionTitle: String {
|
||||
guard let section = dataStatusLabel.superview as? NSStackView,
|
||||
let titleLabel = section.arrangedSubviews.first as? NSTextField else {
|
||||
return ""
|
||||
}
|
||||
return titleLabel.stringValue
|
||||
}
|
||||
|
||||
var debugPasteStatusText: String {
|
||||
pasteStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugAccessibilityStatusText: String {
|
||||
accessibilityStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugCaptureStatusText: String {
|
||||
captureStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugCaptureStatusColor: NSColor {
|
||||
captureStatusLabel.textColor ?? .clear
|
||||
}
|
||||
|
||||
var debugStatusLabelsAllowWrapping: Bool {
|
||||
[
|
||||
launchStatusLabel,
|
||||
shortcutStatusLabel,
|
||||
captureStatusLabel,
|
||||
accessibilityStatusLabel,
|
||||
pasteStatusLabel,
|
||||
cacheLabel,
|
||||
dataStatusLabel,
|
||||
cloudSyncStatusLabel
|
||||
].allSatisfy { label in
|
||||
label.lineBreakMode == .byWordWrapping
|
||||
&& label.maximumNumberOfLines == 0
|
||||
&& !label.usesSingleLineMode
|
||||
&& (label.cell?.wraps ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
var debugDefaultSortTitle: String {
|
||||
defaultSortPopup.selectedItem?.title ?? ""
|
||||
}
|
||||
|
||||
var debugPruneDuplicatesIsEnabled: Bool {
|
||||
pruneDuplicatesButton.state == .on
|
||||
}
|
||||
|
||||
var debugKeepFirstImageIsEnabled: Bool {
|
||||
keepFirstImageButton.state == .on
|
||||
}
|
||||
|
||||
var debugIncludeImageTextIsEnabled: Bool {
|
||||
includeImageTextButton.state == .on
|
||||
}
|
||||
|
||||
var debugExcludeSensitiveIsEnabled: Bool {
|
||||
excludeSensitiveButton.state == .on
|
||||
}
|
||||
|
||||
var debugClearHistoryOnQuitIsEnabled: Bool {
|
||||
clearHistoryOnQuitButton.state == .on
|
||||
}
|
||||
|
||||
var debugLaunchAtLoginIsEnabled: Bool {
|
||||
launchAtLoginButton.state == .on
|
||||
}
|
||||
|
||||
var debugShowMenuBarIconIsEnabled: Bool {
|
||||
showMenuBarIconButton.state == .on
|
||||
}
|
||||
|
||||
var debugShowDockIconIsEnabled: Bool {
|
||||
showDockIconButton.state == .on
|
||||
}
|
||||
|
||||
var debugLaunchStatusText: String {
|
||||
launchStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugOpenShortcutKeyText: String {
|
||||
openShortcutControls?.keyField.stringValue ?? ""
|
||||
}
|
||||
|
||||
var debugSettingsShortcutKeyText: String {
|
||||
settingsShortcutControls?.keyField.stringValue ?? ""
|
||||
}
|
||||
|
||||
var debugShortcutStatusText: String {
|
||||
shortcutStatusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugOpenShortcutModifierAccessibilityLabels: [String] {
|
||||
guard let controls = openShortcutControls else { return [] }
|
||||
return [controls.command, controls.option, controls.control, controls.shift]
|
||||
.compactMap { $0.accessibilityLabel() }
|
||||
}
|
||||
|
||||
var debugOpenShortcutModifierAccessibilityHelps: [String] {
|
||||
guard let controls = openShortcutControls else { return [] }
|
||||
return [controls.command, controls.option, controls.control, controls.shift]
|
||||
.compactMap { $0.accessibilityHelp() }
|
||||
}
|
||||
|
||||
var debugSettingsContentView: NSView? {
|
||||
window?.contentView
|
||||
}
|
||||
|
||||
func debugSelectSettingsTab(at index: Int) {
|
||||
guard index >= 0, index < tabView.numberOfTabViewItems else { return }
|
||||
tabSelector.selectedSegment = index
|
||||
tabView.selectTabViewItem(at: index)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func debugPrepareWindowForSnapshot() {
|
||||
window?.setFrameOrigin(.zero)
|
||||
window?.orderFront(nil)
|
||||
window?.contentView?.layoutSubtreeIfNeeded()
|
||||
window?.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
var debugFullRefreshCount: Int {
|
||||
debugFullRefreshCountValue
|
||||
}
|
||||
|
||||
var debugIgnoredAppsRefreshCount: Int {
|
||||
debugIgnoredAppsRefreshCountValue
|
||||
}
|
||||
|
||||
var debugIgnoredAppsText: String {
|
||||
ignoredAppsTextView.string
|
||||
}
|
||||
|
||||
var debugIgnoredAppsEditorIsFocused: Bool {
|
||||
isEditingIgnoredApps
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func debugFocusIgnoredAppsEditor() -> Bool {
|
||||
textDidBeginEditing(Notification(name: NSText.didBeginEditingNotification, object: ignoredAppsTextView))
|
||||
_ = window?.makeFirstResponder(ignoredAppsTextView)
|
||||
return isEditingIgnoredApps
|
||||
}
|
||||
|
||||
func debugSetIgnoredAppsText(_ text: String) {
|
||||
ignoredAppsTextView.string = text
|
||||
textDidChange(Notification(name: NSText.didChangeNotification, object: ignoredAppsTextView))
|
||||
}
|
||||
|
||||
func debugSetHistoryStepperValue(_ value: Int) {
|
||||
historyStepper.integerValue = value
|
||||
historyLengthChanged()
|
||||
}
|
||||
|
||||
func debugSetCacheSliderMegabytes(_ value: Int) {
|
||||
cacheSlider.doubleValue = Double(value)
|
||||
cacheLimitChanged()
|
||||
}
|
||||
|
||||
func debugEndIgnoredAppsEditing() {
|
||||
window?.makeFirstResponder(nil)
|
||||
textDidEndEditing(Notification(name: NSText.didEndEditingNotification, object: ignoredAppsTextView))
|
||||
}
|
||||
|
||||
func debugCloseWindow() {
|
||||
windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window))
|
||||
window?.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func debugBeginOpenShortcutKeyEditing() -> Bool {
|
||||
guard let field = openShortcutControls?.keyField else { return false }
|
||||
controlTextDidBeginEditing(Notification(name: NSControl.textDidBeginEditingNotification, object: field))
|
||||
_ = window?.makeFirstResponder(field)
|
||||
return isEditingShortcutKeyField(field)
|
||||
}
|
||||
|
||||
func debugSetOpenShortcutKeyDraft(_ text: String) {
|
||||
openShortcutControls?.keyField.stringValue = text
|
||||
}
|
||||
|
||||
func debugEndOpenShortcutKeyEditing() {
|
||||
guard let field = openShortcutControls?.keyField else { return }
|
||||
window?.makeFirstResponder(nil)
|
||||
controlTextDidEndEditing(Notification(name: NSControl.textDidEndEditingNotification, object: field))
|
||||
}
|
||||
|
||||
func debugCommitOpenShortcutKeyText(_ text: String) {
|
||||
guard let controls = openShortcutControls else { return }
|
||||
controls.keyField.stringValue = text
|
||||
shortcutChanged(controls.keyField)
|
||||
}
|
||||
|
||||
func debugSetOpenShortcutModifiers(command: Bool, option: Bool, control: Bool, shift: Bool) {
|
||||
guard let controls = openShortcutControls else { return }
|
||||
controls.command.state = command ? .on : .off
|
||||
controls.option.state = option ? .on : .off
|
||||
controls.control.state = control ? .on : .off
|
||||
controls.shift.state = shift ? .on : .off
|
||||
shortcutChanged(controls.command)
|
||||
}
|
||||
|
||||
func debugCommitSettingsShortcutKeyText(_ text: String) {
|
||||
guard let controls = settingsShortcutControls else { return }
|
||||
controls.keyField.stringValue = text
|
||||
shortcutChanged(controls.keyField)
|
||||
}
|
||||
|
||||
func debugSetLaunchAtLoginEnabled(_ enabled: Bool) {
|
||||
launchAtLoginButton.state = enabled ? .on : .off
|
||||
launchAtLoginChanged()
|
||||
}
|
||||
|
||||
func debugSetShowMenuBarIconEnabled(_ enabled: Bool) {
|
||||
showMenuBarIconButton.state = enabled ? .on : .off
|
||||
showMenuBarIconChanged()
|
||||
}
|
||||
|
||||
func debugSetShowDockIconEnabled(_ enabled: Bool) {
|
||||
showDockIconButton.state = enabled ? .on : .off
|
||||
showDockIconChanged()
|
||||
}
|
||||
|
||||
func debugSetICloudSyncEnabled(_ enabled: Bool) {
|
||||
iCloudSyncButton.state = enabled ? .on : .off
|
||||
iCloudSyncChanged()
|
||||
}
|
||||
|
||||
func debugSetDestructiveActionConfirmation(_ confirmed: Bool?) {
|
||||
debugDestructiveActionConfirmationOverride = confirmed
|
||||
}
|
||||
|
||||
func debugClearClipboardHistory() {
|
||||
clearClipboardHistory()
|
||||
}
|
||||
|
||||
func debugClearThumbnailCache() {
|
||||
clearThumbnailCache()
|
||||
}
|
||||
|
||||
func debugAllowedKindIsEnabled(_ kind: ClipboardItemKind) -> Bool {
|
||||
allowedKindButtons.first { $0.0 == kind }?.1.state == .on
|
||||
}
|
||||
|
||||
func debugSetAllowedKindEnabled(_ kind: ClipboardItemKind, _ enabled: Bool) {
|
||||
guard let button = allowedKindButtons.first(where: { $0.0 == kind })?.1 else { return }
|
||||
button.state = enabled ? .on : .off
|
||||
allowedKindChanged(button)
|
||||
}
|
||||
|
||||
func debugRefreshAccessibilityPermissionStatus() {
|
||||
refreshAccessibilityPermissionStatus()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct ShortcutControlSet {
|
||||
|
||||
@@ -4,21 +4,6 @@ import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardPanelControllerTests: XCTestCase {
|
||||
private var tempURLs: [URL] = []
|
||||
private var defaultsSuites: [String] = []
|
||||
|
||||
override func tearDown() {
|
||||
for url in tempURLs {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
tempURLs.removeAll()
|
||||
for suite in defaultsSuites {
|
||||
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
|
||||
}
|
||||
defaultsSuites.removeAll()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testPanelFrameUsesRightSideShelfByDefault() {
|
||||
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
@@ -31,7 +16,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||
}
|
||||
|
||||
func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() {
|
||||
func testOpenScreenSelectionPrefersExplicitThenPointerScreen() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedOpenScreen(
|
||||
explicit: "status-item-screen",
|
||||
@@ -41,9 +26,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
),
|
||||
"status-item-screen"
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedOpenScreen(
|
||||
explicit: Optional<String>.none,
|
||||
@@ -141,31 +123,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
|
||||
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
|
||||
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
for _ in 0..<50 {
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
XCTAssertEqual(frames.shown, first.shown)
|
||||
XCTAssertEqual(frames.hidden, first.hidden)
|
||||
XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
|
||||
let profile = ClipboardPanelController.animationProfile
|
||||
|
||||
XCTAssertEqual(profile.showDuration, 0.22)
|
||||
XCTAssertEqual(profile.hideDuration, 0.16)
|
||||
XCTAssertEqual(profile.reflowDuration, 0.18)
|
||||
XCTAssertLessThanOrEqual(profile.showDuration * 60, 14)
|
||||
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 10)
|
||||
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 11)
|
||||
XCTAssertEqual(profile.easing, .easeInEaseOut)
|
||||
}
|
||||
|
||||
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
|
||||
let behavior = ClipboardPanelController.panelCollectionBehavior
|
||||
|
||||
@@ -175,69 +132,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertFalse(behavior.contains(.canJoinAllSpaces))
|
||||
}
|
||||
|
||||
func testShowingPanelClearsAndCollapsesStaleSearchState() throws {
|
||||
let screen = try XCTUnwrap(NSScreen.screens.first)
|
||||
let (controller, _) = makeController(preferredScreen: screen)
|
||||
|
||||
controller.debugSetSearchFieldText("stale query")
|
||||
drainMainQueue()
|
||||
XCTAssertEqual(controller.debugSearchFieldText, "stale query")
|
||||
XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1)
|
||||
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips")
|
||||
XCTAssertTrue(controller.debugSearchFieldIsVisible)
|
||||
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
|
||||
|
||||
controller.show(preferredScreen: screen)
|
||||
defer { controller.hide(immediate: true) }
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(controller.debugSearchFieldText, "")
|
||||
XCTAssertEqual(controller.debugSearchFieldWidth, 30, accuracy: 1)
|
||||
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "")
|
||||
XCTAssertFalse(controller.debugSearchFieldIsVisible)
|
||||
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
|
||||
XCTAssertFalse(controller.debugIsSearchFieldEditing)
|
||||
}
|
||||
|
||||
func testReflowPlanKeepsOpenPanelOnRightSideWhenBottomDockIsVisible() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
|
||||
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(plan.frame.width, 336)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 20)
|
||||
}
|
||||
|
||||
func testReflowPlanTouchesScreenBottomWhenBottomDockIsAutoHidden() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
|
||||
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(plan.frame.width, 336)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 18)
|
||||
}
|
||||
|
||||
func testReflowPlanTracksSideDockVisibleFrameWithoutBottomInsetInflation() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minX, visibleFrame.maxX - 336)
|
||||
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 957)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 18)
|
||||
}
|
||||
|
||||
func testPanelFrameUsesConfiguredRightSideShelf() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
@@ -289,22 +183,20 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
}
|
||||
|
||||
func testContentBottomInsetReservesBottomDockSpace() {
|
||||
func testContentBottomInsetHandlesBottomAndSideDock() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
|
||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(inset, 20)
|
||||
}
|
||||
|
||||
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||
|
||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(inset, 18)
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.contentBottomInset(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||
),
|
||||
18
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
|
||||
@@ -326,41 +218,38 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCommandNumberShortcutsMapToQuickPasteSlots() {
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8)
|
||||
assertShortcutMappings([
|
||||
(18, .command, 0), (19, .command, 1), (20, .command, 2),
|
||||
(21, .command, 3), (23, .command, 4), (22, .command, 5),
|
||||
(26, .command, 6), (28, .command, 7), (25, .command, 8)
|
||||
], using: ClipboardPanelController.quickPasteIndex)
|
||||
}
|
||||
|
||||
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
|
||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8)
|
||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command))
|
||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift]))
|
||||
assertShortcutMappings([
|
||||
(18, [.command, .shift], 0),
|
||||
(25, [.command, .shift], 8),
|
||||
(18, .command, nil),
|
||||
(18, [.command, .option, .shift], nil)
|
||||
], using: ClipboardPanelController.quickPastePlainTextIndex)
|
||||
}
|
||||
|
||||
func testCommandOptionNumberShortcutsMapToCollections() {
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
|
||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code)
|
||||
}
|
||||
|
||||
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command))
|
||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
|
||||
assertShortcutMappings([
|
||||
(18, [.command, .option], .mostRecent),
|
||||
(19, [.command, .option], .mostUsed),
|
||||
(20, [.command, .option], .text),
|
||||
(21, [.command, .option], .links),
|
||||
(23, [.command, .option], .images),
|
||||
(22, [.command, .option], .files),
|
||||
(26, [.command, .option], .pinned),
|
||||
(28, [.command, .option], .audio),
|
||||
(25, [.command, .option], .colors),
|
||||
(29, [.command, .option], .code),
|
||||
(18, [], nil),
|
||||
(18, .command, nil),
|
||||
(29, .command, nil)
|
||||
], using: ClipboardPanelController.collectionShortcutMode)
|
||||
}
|
||||
|
||||
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
|
||||
@@ -372,60 +261,35 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testNavigationShortcutsMapToShelfMovement() {
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: .command), .first)
|
||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: .command), .last)
|
||||
}
|
||||
|
||||
func testNavigationShortcutsRejectUnsupportedModifiers() {
|
||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command))
|
||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift))
|
||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: []))
|
||||
assertShortcutMappings([
|
||||
(115, [], .first), (119, [], .last), (124, [], .next),
|
||||
(121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous),
|
||||
(126, .command, .first), (125, .command, .last),
|
||||
(124, .command, nil), (126, [], nil), (125, [], nil),
|
||||
(121, .shift, nil), (35, [], nil)
|
||||
], using: ClipboardPanelController.navigationShortcutAction)
|
||||
}
|
||||
|
||||
func testSelectionShortcutsMapToRangeAndSelectAllActions() {
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: .command), .selectAll)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 115, modifiers: .shift), .extendFirst)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 119, modifiers: .shift), .extendLast)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: .shift), .extendNext)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 121, modifiers: .shift), .extendPageNext)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 116, modifiers: .shift), .extendPagePrevious)
|
||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 123, modifiers: .shift), .extendPrevious)
|
||||
}
|
||||
|
||||
func testSelectionShortcutsRequireExactModifierSets() {
|
||||
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: [.command, .shift]))
|
||||
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: [.command, .shift]))
|
||||
assertShortcutMappings([
|
||||
(0, .command, .selectAll),
|
||||
(115, .shift, .extendFirst), (119, .shift, .extendLast),
|
||||
(124, .shift, .extendNext), (121, .shift, .extendPageNext),
|
||||
(116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious),
|
||||
(0, [], nil), (0, [.command, .shift], nil),
|
||||
(124, [], nil), (124, [.command, .shift], nil)
|
||||
], using: ClipboardPanelController.selectionShortcutAction)
|
||||
}
|
||||
|
||||
func testCommandActionShortcutsMapToSelectedClipActions() {
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 14, modifiers: .command), .edit)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 3, modifiers: .command), .focusSearch)
|
||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 45, modifiers: .command))
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .rename)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 17, modifiers: .command), .toggleCapturePause)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 6, modifiers: .command), .undoDelete)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 123, modifiers: .command), .previousCollection)
|
||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 124, modifiers: .command), .nextCollection)
|
||||
}
|
||||
|
||||
func testCommandActionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
|
||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]))
|
||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
|
||||
assertShortcutMappings([
|
||||
(8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch),
|
||||
(45, .command, nil), (5, .command, .showInClipboard),
|
||||
(16, .command, .preview), (31, .command, .open), (15, .command, .rename),
|
||||
(17, .command, .toggleCapturePause), (6, .command, .undoDelete),
|
||||
(123, .command, .previousCollection), (124, .command, .nextCollection),
|
||||
(8, [], nil), (8, [.command, .shift], nil), (9, .command, nil)
|
||||
], using: ClipboardPanelController.commandShortcutAction)
|
||||
}
|
||||
|
||||
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
|
||||
@@ -455,67 +319,32 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testModifiedShortcutsMapToPanelActions() {
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText)
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .toggleStackCapture)
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection)
|
||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
|
||||
assertShortcutMappings([
|
||||
(36, .shift, .pastePlainText),
|
||||
(1, [.command, .shift], .toggleStack),
|
||||
(8, [.command, .shift], .toggleStackCapture),
|
||||
(9, [.command, .shift], .pastePlainText),
|
||||
(45, [.command, .shift], .newCollection),
|
||||
(36, [.command, .shift], .pasteStackNext),
|
||||
(36, [], nil), (8, .shift, nil), (8, .command, nil),
|
||||
(8, [.command, .option, .shift], nil), (31, [.command, .shift], nil)
|
||||
], using: ClipboardPanelController.modifiedShortcutAction)
|
||||
}
|
||||
|
||||
func testModifiedShortcutsRequireCommandShiftOnly() {
|
||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: []))
|
||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .shift))
|
||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command))
|
||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift]))
|
||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift]))
|
||||
}
|
||||
|
||||
private func makeController(preferredScreen: NSScreen) -> (ClipboardPanelController, ClipboardStore) {
|
||||
let settings = makeSettings()
|
||||
let encryptionService = ClipboardEncryptionService(keyProvider: { nil })
|
||||
let cacheService = ClipboardCacheService(
|
||||
baseURL: makeTempDirectory(),
|
||||
encryptionService: encryptionService
|
||||
private func assertShortcutMappings<Value: Equatable>(
|
||||
_ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)],
|
||||
using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
for testCase in cases {
|
||||
XCTAssertEqual(
|
||||
mapping(testCase.keyCode, testCase.modifiers),
|
||||
testCase.expected,
|
||||
"keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)",
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
let store = ClipboardStore(
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
baseURL: makeTempDirectory(),
|
||||
encryptionService: encryptionService
|
||||
)
|
||||
let controller = ClipboardPanelController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
preferredScreen: { preferredScreen }
|
||||
)
|
||||
return (controller, store)
|
||||
}
|
||||
|
||||
private func makeSettings() -> SettingsModel {
|
||||
let suite = "com.clipbored.controllertest.\(UUID().uuidString)"
|
||||
defaultsSuites.append(suite)
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
settings.maxHistoryItems = 10
|
||||
settings.historyRetention = .forever
|
||||
settings.pruneDuplicates = false
|
||||
return settings
|
||||
}
|
||||
|
||||
private func makeTempDirectory() -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipbored-controllertest")
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
tempURLs.append(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func drainMainQueue() {
|
||||
for _ in 0..<24 {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,294 +39,6 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
|
||||
}
|
||||
|
||||
func testSelectingCollectionWithEmptySearchUsesIndexedVisibleItems() {
|
||||
let settings = makeSettings()
|
||||
settings.maxHistoryItems = 260
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
|
||||
for index in 0..<220 {
|
||||
store.upsert(makeTextItem("outside indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
|
||||
}
|
||||
for index in 0..<30 {
|
||||
var item = makeTextItem("client indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
|
||||
item.collectionName = "Client Work"
|
||||
store.upsert(item)
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 250)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.selectCollection(named: "Client Work")
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.count, 30)
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 1)
|
||||
XCTAssertEqual(viewModel.visibleItems.first?.payload, "client indexed category 29")
|
||||
}
|
||||
|
||||
func testRepeatedThirtyItemCollectionSelectionsStayOnIndexedFastPath() {
|
||||
let collectionCount = 60
|
||||
let itemsPerCollection = 30
|
||||
let settings = makeSettings()
|
||||
settings.maxHistoryItems = collectionCount * itemsPerCollection
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
let collectionNames = (0..<collectionCount).map { "Client Work \($0)" }
|
||||
|
||||
for (collectionIndex, collectionName) in collectionNames.enumerated() {
|
||||
for itemIndex in 0..<itemsPerCollection {
|
||||
var item = makeTextItem(
|
||||
"\(collectionName) clip \(itemIndex)",
|
||||
createdAt: Date(timeIntervalSince1970: Double((collectionIndex * itemsPerCollection) + itemIndex))
|
||||
)
|
||||
item.collectionName = collectionName
|
||||
store.upsert(item)
|
||||
}
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: collectionCount * itemsPerCollection)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
let start = CFAbsoluteTimeGetCurrent()
|
||||
for collectionName in collectionNames {
|
||||
viewModel.selectCollection(named: collectionName)
|
||||
XCTAssertEqual(viewModel.visibleItems.count, itemsPerCollection)
|
||||
}
|
||||
let elapsed = CFAbsoluteTimeGetCurrent() - start
|
||||
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, collectionCount)
|
||||
XCTAssertLessThan(elapsed, 0.20)
|
||||
}
|
||||
|
||||
func testCommandSelectedCategoriesUseIndexedUnionFastPath() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
|
||||
let text = makeTextItem("category text", createdAt: Date(timeIntervalSince1970: 100))
|
||||
let link = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
displayText: "category link",
|
||||
payload: "https://example.com/category",
|
||||
payloadHash: store.hashString("https://example.com/category"),
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
store.upsert(text)
|
||||
store.upsert(link)
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.selectSortMode(.text, extending: true)
|
||||
viewModel.selectSortMode(.links, extending: true)
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "category text"])
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
|
||||
XCTAssertTrue(viewModel.isSortModeCategorySelected(.text))
|
||||
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
|
||||
}
|
||||
|
||||
func testCommandSelectedCustomCollectionCombinesWithCategoryFilter() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
|
||||
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
|
||||
client.collectionName = "Client Work"
|
||||
let link = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
displayText: "category link",
|
||||
payload: "https://example.com/category",
|
||||
payloadHash: store.hashString("https://example.com/category"),
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
store.upsert(client)
|
||||
store.upsert(link)
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.selectCollection(named: "Client Work")
|
||||
viewModel.selectSortMode(.links, extending: true)
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "client note"])
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
|
||||
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
|
||||
XCTAssertTrue(viewModel.isCollectionCategorySelected(named: "Client Work"))
|
||||
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
|
||||
}
|
||||
|
||||
func testEmptySearchCollectionCountSummaryUsesIndexedCounts() {
|
||||
let settings = makeSettings()
|
||||
settings.maxHistoryItems = 260
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
|
||||
for index in 0..<220 {
|
||||
store.upsert(makeTextItem("outside indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
|
||||
}
|
||||
for index in 0..<30 {
|
||||
var item = makeTextItem("client indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
|
||||
item.collectionName = "Client Work"
|
||||
store.upsert(item)
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 250)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
let counts = viewModel.collectionCountSummary()
|
||||
|
||||
XCTAssertEqual(counts.count(for: .mostRecent), 250)
|
||||
XCTAssertEqual(counts.count(for: .text), 250)
|
||||
XCTAssertEqual(counts.count(named: "Client Work"), 30)
|
||||
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 0)
|
||||
XCTAssertEqual(viewModel.debugCollectionCountIndexedLookupCount, 1)
|
||||
}
|
||||
|
||||
func testSearchMatchesAreEvaluatedOnceAcrossVisibleItemsCountsAndCategoryChanges() {
|
||||
let settings = makeSettings()
|
||||
settings.maxHistoryItems = 120
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
|
||||
for index in 0..<100 {
|
||||
var item = makeTextItem(
|
||||
index.isMultiple(of: 5) ? "shared search needle \(index)" : "unmatched history \(index)",
|
||||
createdAt: Date(timeIntervalSince1970: Double(index))
|
||||
)
|
||||
if index.isMultiple(of: 2) {
|
||||
item.collectionName = "Client Work"
|
||||
}
|
||||
store.upsert(item)
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 100)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.searchText = "needle"
|
||||
XCTAssertEqual(viewModel.visibleItems.count, 20)
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
|
||||
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
|
||||
|
||||
let counts = viewModel.collectionCountSummary()
|
||||
XCTAssertEqual(counts.count(for: .text), 20)
|
||||
XCTAssertEqual(counts.count(named: "Client Work"), 10)
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
|
||||
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 1)
|
||||
|
||||
viewModel.selectSortMode(.text, extending: true)
|
||||
viewModel.selectSortMode(.links, extending: true)
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.count, 20)
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
|
||||
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 3)
|
||||
|
||||
viewModel.searchText = "shared"
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.count, 20)
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 200)
|
||||
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
|
||||
XCTAssertEqual(viewModel.debugSearchDocumentCacheHitCount, 100)
|
||||
}
|
||||
|
||||
func testEquivalentDiacriticSearchReusesVisibleAndCollectionCountCaches() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
store.upsert(makeTextItem("Résumé draft", createdAt: Date(timeIntervalSince1970: 100)))
|
||||
store.upsert(makeTextItem("Meeting note", createdAt: Date(timeIntervalSince1970: 200)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.searchText = "resume"
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
|
||||
_ = viewModel.collectionCountSummary()
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
|
||||
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
|
||||
|
||||
viewModel.searchText = " RÉSUMÉ "
|
||||
_ = viewModel.collectionCountSummary()
|
||||
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
|
||||
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
|
||||
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
|
||||
}
|
||||
|
||||
func testCategoryFilterSelectionIsBuiltOncePerMutationAndReusedForChipStateQueries() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
store.upsert(makeTextItem("category state", createdAt: Date(timeIntervalSince1970: 100)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 1)
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.selectSortMode(.text, extending: true)
|
||||
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
|
||||
|
||||
for _ in 0..<20 {
|
||||
for mode in ClipboardSortMode.allCases {
|
||||
_ = viewModel.isSortModeCategorySelected(mode)
|
||||
}
|
||||
_ = viewModel.isCollectionCategorySelected(named: "Client Work")
|
||||
_ = viewModel.canShowVisibleItemsInClipboard
|
||||
}
|
||||
|
||||
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
|
||||
}
|
||||
|
||||
func testRepeatedRecomputesDoNotRescanUnchangedStackMembership() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
store.upsert(makeTextItem("stacked needle", createdAt: Date(timeIntervalSince1970: 100)))
|
||||
store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.selectItem(at: 1)
|
||||
viewModel.toggleSelectedStackMembership()
|
||||
viewModel.debugResetVisibleItemsPerformanceCounters()
|
||||
|
||||
viewModel.searchText = "needle"
|
||||
viewModel.clearSearch()
|
||||
viewModel.selectSortMode(.text)
|
||||
viewModel.selectSortMode(.mostRecent)
|
||||
|
||||
XCTAssertEqual(viewModel.stackCount, 1)
|
||||
XCTAssertEqual(viewModel.debugStackPruneScanCount, 0)
|
||||
}
|
||||
|
||||
func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() {
|
||||
let settings = makeSettings()
|
||||
@@ -405,140 +117,65 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(completionMainThreadValues, [true, true])
|
||||
}
|
||||
|
||||
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
|
||||
func testContentKindsSupportCategoryStructuredAndTextSearch() {
|
||||
let settings = makeSettings()
|
||||
let store = makeStore(settings: settings)
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
|
||||
let color = ClipboardItem(
|
||||
id: UUID(),
|
||||
let color = makeItem(
|
||||
kind: .color,
|
||||
displayText: "#0A84FF",
|
||||
payload: "#0A84FF",
|
||||
payloadHash: hash("#0A84FF"),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
||||
useCount: 0,
|
||||
sourceApp: "Design Tool",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
timestamp: 100,
|
||||
sourceApp: "Design Tool"
|
||||
)
|
||||
let text = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Color note",
|
||||
payload: "Color note",
|
||||
payloadHash: hash("Color note"),
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 0,
|
||||
sourceApp: "Notes",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [text, color], query: "", sortMode: .colors).map(\.payload),
|
||||
["#0A84FF"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [text, color], query: "type:swatch", sortMode: .mostRecent).map(\.payload),
|
||||
["#0A84FF"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [text, color], query: "hex 0a84ff", sortMode: .mostRecent).map(\.payload),
|
||||
["#0A84FF"]
|
||||
)
|
||||
}
|
||||
|
||||
func testComputeVisibleItemsFiltersCodeSnippetsAndKeepsThemInTextView() {
|
||||
let settings = makeSettings()
|
||||
let store = makeStore(settings: settings)
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
|
||||
let code = ClipboardItem(
|
||||
id: UUID(),
|
||||
let code = makeItem(
|
||||
kind: .code,
|
||||
displayText: "Swift Snippet",
|
||||
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
|
||||
payloadHash: hash("swift-snippet"),
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 0,
|
||||
sourceApp: "Xcode",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
timestamp: 300,
|
||||
sourceApp: "Xcode"
|
||||
)
|
||||
let text = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: "Meeting note",
|
||||
payload: "Meeting note",
|
||||
payloadHash: hash("Meeting note"),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
||||
useCount: 0,
|
||||
sourceApp: "Notes",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .code).map(\.kind),
|
||||
[.code]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .text).map(\.kind),
|
||||
[.code, .text]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [code, text], query: "type:snippet greet", sortMode: .mostRecent).map(\.kind),
|
||||
[.code]
|
||||
)
|
||||
}
|
||||
|
||||
func testComputeVisibleItemsFiltersVideoClipsAndStructuredType() {
|
||||
let settings = makeSettings()
|
||||
let store = makeStore(settings: settings)
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
|
||||
let video = ClipboardItem(
|
||||
id: UUID(),
|
||||
let video = makeItem(
|
||||
kind: .video,
|
||||
displayText: "Video (12 KB)",
|
||||
payload: "/tmp/clip.mp4",
|
||||
payloadHash: hash("clip-video"),
|
||||
createdAt: Date(timeIntervalSince1970: 200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
||||
useCount: 0,
|
||||
sourceApp: "QuickTime Player",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
timestamp: 200,
|
||||
sourceApp: "QuickTime Player"
|
||||
)
|
||||
let image = ClipboardItem(
|
||||
id: UUID(),
|
||||
let text = makeItem(
|
||||
kind: .text,
|
||||
displayText: "Meeting note",
|
||||
payload: "Meeting note",
|
||||
timestamp: 100,
|
||||
sourceApp: "Notes"
|
||||
)
|
||||
let image = makeItem(
|
||||
kind: .image,
|
||||
displayText: "Image",
|
||||
payload: "/tmp/image.png",
|
||||
payloadHash: hash("image"),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
||||
useCount: 0,
|
||||
sourceApp: "Preview",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
timestamp: 100,
|
||||
sourceApp: "Preview"
|
||||
)
|
||||
|
||||
let items = [color, code, video, text, image]
|
||||
let cases: [(ClipboardSortMode, String, [ClipboardItemKind])] = [
|
||||
(.colors, "", [.color]),
|
||||
(.mostRecent, "type:swatch", [.color]),
|
||||
(.mostRecent, "hex 0a84ff", [.color]),
|
||||
(.code, "", [.code]),
|
||||
(.text, "", [.code, .text]),
|
||||
(.mostRecent, "type:snippet greet", [.code]),
|
||||
(.videos, "", [.video]),
|
||||
(.mostRecent, "type:movie", [.video]),
|
||||
(.mostRecent, "mp4", [.video])
|
||||
]
|
||||
for (sortMode, query, expectedKinds) in cases {
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [video, image], query: "", sortMode: .videos).map(\.kind),
|
||||
[.video]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [video, image], query: "type:movie", sortMode: .mostRecent).map(\.kind),
|
||||
[.video]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
viewModel.computeVisibleItems(from: [video, image], query: "mp4", sortMode: .mostRecent).map(\.kind),
|
||||
[.video]
|
||||
viewModel.computeVisibleItems(from: items, query: query, sortMode: sortMode).map(\.kind),
|
||||
expectedKinds,
|
||||
"sort=\(sortMode), query=\(query)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testSearchMatchesIndependentTokensCaseInsensitively() {
|
||||
let settings = makeSettings()
|
||||
@@ -2547,7 +2184,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
viewModel.searchText = "second"
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
|
||||
|
||||
viewModel.clearSearch()
|
||||
viewModel.searchText = ""
|
||||
viewModel.sortMode = .text
|
||||
XCTAssertFalse(viewModel.isStackFilterSelected)
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
|
||||
@@ -2566,7 +2203,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
|
||||
viewModel.searchText = "draft"
|
||||
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
|
||||
viewModel.clearSearch()
|
||||
viewModel.searchText = ""
|
||||
|
||||
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
|
||||
viewModel.updateSelectedText(to: "final launch note")
|
||||
@@ -2634,8 +2271,8 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
|
||||
XCTAssertNil(viewModel.editableTextForItem(at: 0))
|
||||
viewModel.selectItem(at: 0)
|
||||
XCTAssertNil(viewModel.editableTextForSelected())
|
||||
viewModel.updateSelectedText(to: "should not apply")
|
||||
XCTAssertEqual(store.items.first?.payload, file.payload)
|
||||
|
||||
@@ -2963,18 +2600,32 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
|
||||
makeItem(kind: .text, displayText: text, payload: text, timestamp: createdAt.timeIntervalSince1970)
|
||||
}
|
||||
|
||||
private func makeItem(
|
||||
kind: ClipboardItemKind,
|
||||
displayText: String,
|
||||
payload: String,
|
||||
timestamp: TimeInterval,
|
||||
lastUsedTimestamp: TimeInterval? = nil,
|
||||
useCount: Int = 0,
|
||||
sourceApp: String? = nil,
|
||||
isPinned: Bool = false
|
||||
) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: text,
|
||||
payload: text,
|
||||
payloadHash: hash(text),
|
||||
createdAt: createdAt,
|
||||
lastUsedAt: createdAt,
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
kind: kind,
|
||||
displayText: displayText,
|
||||
payload: payload,
|
||||
payloadHash: hash(payload),
|
||||
createdAt: Date(timeIntervalSince1970: timestamp),
|
||||
lastUsedAt: Date(timeIntervalSince1970: lastUsedTimestamp ?? timestamp),
|
||||
useCount: useCount,
|
||||
sourceApp: sourceApp,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
thumbnailPath: nil,
|
||||
isPinned: isPinned
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3031,88 +2682,56 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
|
||||
private func makeSampleItems() -> [ClipboardItem] {
|
||||
[
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .text,
|
||||
displayText: "Project notes",
|
||||
payload: "one",
|
||||
payloadHash: hash("one"),
|
||||
createdAt: Date(timeIntervalSince1970: 1000),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1000),
|
||||
timestamp: 1000,
|
||||
useCount: 2,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: true
|
||||
),
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .richText,
|
||||
displayText: "Two",
|
||||
payload: "two",
|
||||
payloadHash: hash("two"),
|
||||
createdAt: Date(timeIntervalSince1970: 1100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1080),
|
||||
timestamp: 1100,
|
||||
lastUsedTimestamp: 1080,
|
||||
useCount: 4,
|
||||
sourceApp: "Mail",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
sourceApp: "Mail"
|
||||
),
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .url,
|
||||
displayText: "Apple",
|
||||
payload: "https://apple.com",
|
||||
payloadHash: hash("https://apple.com"),
|
||||
createdAt: Date(timeIntervalSince1970: 1030),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1050),
|
||||
timestamp: 1030,
|
||||
lastUsedTimestamp: 1050,
|
||||
useCount: 1,
|
||||
sourceApp: "Safari",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
sourceApp: "Safari"
|
||||
),
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .file,
|
||||
displayText: "report.pdf",
|
||||
payload: "/tmp/report.pdf",
|
||||
payloadHash: hash("/tmp/report.pdf"),
|
||||
createdAt: Date(timeIntervalSince1970: 1060),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1070),
|
||||
timestamp: 1060,
|
||||
lastUsedTimestamp: 1070,
|
||||
useCount: 3,
|
||||
sourceApp: "Finder",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
sourceApp: "Finder"
|
||||
),
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .audio,
|
||||
displayText: "Voice memo",
|
||||
payload: "/tmp/voice.sound",
|
||||
payloadHash: hash("/tmp/voice.sound"),
|
||||
createdAt: Date(timeIntervalSince1970: 1040),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1060),
|
||||
timestamp: 1040,
|
||||
lastUsedTimestamp: 1060,
|
||||
useCount: 2,
|
||||
sourceApp: "Voice Memos",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
sourceApp: "Voice Memos"
|
||||
),
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
makeItem(
|
||||
kind: .text,
|
||||
displayText: "Four",
|
||||
payload: "four",
|
||||
payloadHash: hash("four"),
|
||||
createdAt: Date(timeIntervalSince1970: 1200),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 1200),
|
||||
useCount: 0,
|
||||
timestamp: 1200,
|
||||
sourceApp: "Notes",
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: true
|
||||
)
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class DiagnosticsServiceTests: XCTestCase {
|
||||
func testCountersCanBeIncrementedAndReset() {
|
||||
let diagnostics = DiagnosticsService.shared
|
||||
diagnostics.reset()
|
||||
|
||||
diagnostics.incrementMonitorTick()
|
||||
diagnostics.incrementPasteboardChange()
|
||||
diagnostics.incrementExtractionAttempt()
|
||||
diagnostics.incrementDatabaseMutation()
|
||||
diagnostics.incrementCachePurge()
|
||||
|
||||
// The snapshot read synchronizes with the serial diagnostics queue.
|
||||
let snapshot = diagnostics.currentSnapshot()
|
||||
XCTAssertEqual(snapshot.monitorTicks, 1)
|
||||
XCTAssertEqual(snapshot.pasteboardChanges, 1)
|
||||
XCTAssertEqual(snapshot.extractionAttempts, 1)
|
||||
XCTAssertEqual(snapshot.databaseMutations, 1)
|
||||
XCTAssertEqual(snapshot.cachePurges, 1)
|
||||
|
||||
diagnostics.reset()
|
||||
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class LinkPreviewWindowControllerTests: XCTestCase {
|
||||
func testReusedPreviewIgnoresStaleObservedTitleUntilNewNavigationStarts() throws {
|
||||
let controller = LinkPreviewWindowController()
|
||||
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
|
||||
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
|
||||
controller.debugAllowObservedPageTitles()
|
||||
controller.debugApplyObservedPageTitle("Old loaded title")
|
||||
|
||||
XCTAssertEqual(controller.debugTitleText, "Old loaded title")
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
|
||||
controller.debugApplyObservedPageTitle("Old loaded title")
|
||||
|
||||
XCTAssertEqual(controller.debugTitleText, "New request title")
|
||||
XCTAssertEqual(controller.debugAddressText, "https://example.com/new")
|
||||
XCTAssertEqual(controller.debugStatusText, "Loading")
|
||||
|
||||
controller.debugAllowObservedPageTitles()
|
||||
controller.debugApplyObservedPageTitle("New loaded title")
|
||||
|
||||
XCTAssertEqual(controller.debugTitleText, "New loaded title")
|
||||
}
|
||||
|
||||
func testCancelledNavigationDoesNotShowFalseLoadFailure() throws {
|
||||
let controller = LinkPreviewWindowController()
|
||||
let url = try XCTUnwrap(URL(string: "https://example.com/new"))
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "New request title"))
|
||||
controller.debugApplyNavigationFailure(URLError(.cancelled))
|
||||
|
||||
XCTAssertEqual(controller.debugStatusText, "Loading")
|
||||
|
||||
controller.debugApplyNavigationFailure(URLError(.timedOut))
|
||||
|
||||
XCTAssertEqual(controller.debugStatusText, "Could not load")
|
||||
}
|
||||
|
||||
func testToolbarTooltipsTrackFullVisibleText() throws {
|
||||
let controller = LinkPreviewWindowController()
|
||||
let url = try XCTUnwrap(URL(string: "https://example.com/articles/a-very-long-release-note-title?ref=clipbored"))
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "Release note with a long title"))
|
||||
|
||||
XCTAssertEqual(controller.debugTitleTooltip, "Release note with a long title")
|
||||
XCTAssertEqual(controller.debugAddressTooltip, url.absoluteString)
|
||||
XCTAssertEqual(controller.debugStatusTooltip, "Loading")
|
||||
|
||||
controller.debugAllowObservedPageTitles()
|
||||
controller.debugApplyObservedPageTitle("Loaded page title that may truncate in the toolbar")
|
||||
|
||||
XCTAssertEqual(controller.debugTitleTooltip, "Loaded page title that may truncate in the toolbar")
|
||||
|
||||
controller.debugApplyNavigationFailure(URLError(.timedOut))
|
||||
|
||||
XCTAssertEqual(controller.debugStatusTooltip, "Could not load")
|
||||
}
|
||||
|
||||
func testOpenInBrowserUsesDisplayedPageURLAndResetsForReusedPreview() throws {
|
||||
var openedURLs: [URL] = []
|
||||
let controller = LinkPreviewWindowController { openedURLs.append($0) }
|
||||
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
|
||||
let navigatedURL = try XCTUnwrap(URL(string: "https://example.com/old/details"))
|
||||
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
|
||||
controller.debugSetDisplayedPageURL(navigatedURL)
|
||||
controller.debugOpenInBrowser()
|
||||
|
||||
XCTAssertEqual(openedURLs, [navigatedURL])
|
||||
|
||||
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
|
||||
controller.debugOpenInBrowser()
|
||||
|
||||
XCTAssertEqual(openedURLs, [navigatedURL, secondURL])
|
||||
}
|
||||
}
|
||||
@@ -51,25 +51,4 @@ final class OnboardingWindowControllerTests: XCTestCase {
|
||||
XCTAssertTrue(presentation.showDockIcon)
|
||||
}
|
||||
|
||||
func testDisplayedEntryPointSelectionIsNormalizedOnRefresh() {
|
||||
let settings = makeSettings()
|
||||
settings.showMenuBarIcon = false
|
||||
settings.showDockIcon = false
|
||||
|
||||
let controller = OnboardingWindowController(
|
||||
settings: settings,
|
||||
onOpenAccessibility: {},
|
||||
onFinish: {}
|
||||
)
|
||||
|
||||
XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled)
|
||||
XCTAssertFalse(controller.debugShowDockIconIsEnabled)
|
||||
}
|
||||
|
||||
private func makeSettings() -> SettingsModel {
|
||||
let suiteName = "com.clipbored.onboarding.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return SettingsModel(defaults: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ final class SensitiveContentDetectorTests: XCTestCase {
|
||||
|
||||
func testDetectsCreditCardWithLuhnCheck() {
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
||||
XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard)
|
||||
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
|
||||
}
|
||||
|
||||
|
||||
117
tests/clipboredtests/SettingsPresentationTests.swift
Normal file
117
tests/clipboredtests/SettingsPresentationTests.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class SettingsPresentationTests: XCTestCase {
|
||||
func testPasteAndDataStatusColors() {
|
||||
let pasteCases: [(String, NSColor)] = [
|
||||
("", .secondaryLabelColor),
|
||||
("Pasted", .systemGreen),
|
||||
("Copied. Grant Accessibility access to paste automatically.", .systemOrange),
|
||||
("Could not write item to clipboard.", .systemRed)
|
||||
]
|
||||
for (message, color) in pasteCases {
|
||||
XCTAssertEqual(SettingsWindowController.pasteStatusPresentation(storedStatus: message).textColor, color)
|
||||
}
|
||||
|
||||
let dataCases: [(String, NSColor)] = [
|
||||
("", .secondaryLabelColor),
|
||||
("Exported 3 clips.", .systemGreen),
|
||||
("Imported 3 clips. Skipped 1 clip.", .systemOrange),
|
||||
("The archive couldn't be opened.", .systemRed)
|
||||
]
|
||||
for (message, color) in dataCases {
|
||||
XCTAssertEqual(SettingsWindowController.dataStatusPresentation(storedStatus: message).textColor, color)
|
||||
}
|
||||
}
|
||||
|
||||
func testCaptureStatusColors() {
|
||||
let cases: [(String, NSColor)] = [
|
||||
("", .secondaryLabelColor),
|
||||
("Captured text from Safari.", .systemGreen),
|
||||
("Skipped: Audio items are ignored.", .systemOrange),
|
||||
("At least one content type must stay enabled.", .systemOrange),
|
||||
("Error: Clipboard read failed.", .systemRed)
|
||||
]
|
||||
for (message, color) in cases {
|
||||
XCTAssertEqual(SettingsWindowController.captureStatusPresentation(storedStatus: message).textColor, color)
|
||||
}
|
||||
}
|
||||
|
||||
func testShortcutPermissionAndLifecycleStatusColors() {
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.shortcutStatusPresentation(storedStatus: "").textColor,
|
||||
.systemGreen
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.shortcutStatusPresentation(storedStatus: "Unsupported shortcut").textColor,
|
||||
.systemRed
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.accessibilityPermissionStatusPresentation(
|
||||
storedStatus: "",
|
||||
isTrusted: true
|
||||
).textColor,
|
||||
.systemGreen
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.accessibilityPermissionStatusPresentation(
|
||||
storedStatus: "Permission not granted",
|
||||
isTrusted: true
|
||||
).textColor,
|
||||
.systemOrange
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "Service unavailable").textColor,
|
||||
.systemRed
|
||||
)
|
||||
}
|
||||
|
||||
func testCloudSyncStatusPrecedence() {
|
||||
let ready = ClipboardCloudSyncStatus(
|
||||
isAvailable: true,
|
||||
archiveURL: nil,
|
||||
lastModifiedAt: nil,
|
||||
message: "iCloud is ready."
|
||||
)
|
||||
let unavailable = ClipboardCloudSyncStatus(
|
||||
isAvailable: false,
|
||||
archiveURL: nil,
|
||||
lastModifiedAt: nil,
|
||||
message: "iCloud is unavailable."
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.cloudSyncStatusPresentation(
|
||||
storedStatus: "",
|
||||
isSyncEnabled: false,
|
||||
cloudStatus: ready
|
||||
).message,
|
||||
"iCloud Sync is off."
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.cloudSyncStatusPresentation(
|
||||
storedStatus: "",
|
||||
isSyncEnabled: true,
|
||||
cloudStatus: unavailable
|
||||
).textColor,
|
||||
.systemOrange
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.cloudSyncStatusPresentation(
|
||||
storedStatus: "Synced 3 clips.",
|
||||
isSyncEnabled: true,
|
||||
cloudStatus: ready
|
||||
).textColor,
|
||||
.systemGreen
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsWindowController.cloudSyncStatusPresentation(
|
||||
storedStatus: "iCloud Sync failed.",
|
||||
isSyncEnabled: true,
|
||||
cloudStatus: ready
|
||||
).textColor,
|
||||
.systemRed
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,13 +36,6 @@ final class ShortcutManagerTests: XCTestCase {
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
func testGlobalRegistrationExcludesLocalSettingsShortcut() {
|
||||
let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut)
|
||||
|
||||
XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut])
|
||||
XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut))
|
||||
}
|
||||
|
||||
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
|
||||
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user