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`.
|
- 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.
|
- 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.
|
- 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.
|
- Keep UI native and compact. This is a utility, not a marketing surface.
|
||||||
|
|
||||||
## Pull Request Checklist
|
## Pull Request Checklist
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Project layout:
|
|||||||
- `sources/clipbored/resources` - app bundle metadata and icon assets
|
- `sources/clipbored/resources` - app bundle metadata and icon assets
|
||||||
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
|
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
|
||||||
- `sources/clipbored/views` - panel, onboarding, preview, and settings UI
|
- `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
|
- `docs` - architecture, security, release, smoke-test, and roadmap notes
|
||||||
|
|
||||||
## Privacy And Security
|
## 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))"
|
"\(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 {
|
private static func clampedByte(_ value: CGFloat) -> Int {
|
||||||
Int((min(1, max(0, value)) * 255).rounded())
|
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) {
|
func purgeIfNeeded(maxBytes: Int64) {
|
||||||
queue.async {
|
queue.async {
|
||||||
DiagnosticsService.shared.incrementCachePurge()
|
|
||||||
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
|
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
|
||||||
var items: [(url: URL, size: Int64, date: Date)] = []
|
var items: [(url: URL, size: Int64, date: Date)] = []
|
||||||
var totalSize: Int64 = 0
|
var totalSize: Int64 = 0
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ final class ClipboardMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
DiagnosticsService.shared.incrementMonitorTick()
|
|
||||||
pollPasteboard(rescheduleAfterCapture: true)
|
pollPasteboard(rescheduleAfterCapture: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +137,6 @@ final class ClipboardMonitorService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DiagnosticsService.shared.incrementPasteboardChange()
|
|
||||||
|
|
||||||
didReportReadFailure = false
|
didReportReadFailure = false
|
||||||
if let item = readCurrentItem(from: pasteboard) {
|
if let item = readCurrentItem(from: pasteboard) {
|
||||||
@@ -162,7 +160,6 @@ final class ClipboardMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
|
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
|
||||||
DiagnosticsService.shared.incrementExtractionAttempt()
|
|
||||||
let source = frontmostApp()
|
let source = frontmostApp()
|
||||||
|
|
||||||
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
|
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
|
||||||
|
|||||||
@@ -659,78 +659,22 @@ final class ClipboardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func legacyISO8601Date(_ string: String) -> Date? {
|
private func legacyISO8601Date(_ string: String) -> Date? {
|
||||||
string.withCString { pointer -> Date? in
|
let value = string.replacingOccurrences(of: " ", with: "T")
|
||||||
let byteCount = strlen(pointer)
|
return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var cursor = 19
|
private static let legacyDateFormatters: [ISO8601DateFormatter] = {
|
||||||
var fraction = 0.0
|
let formats: [ISO8601DateFormatter.Options] = [
|
||||||
if cursor < byteCount, byte(pointer, cursor) == 46 {
|
[.withInternetDateTime, .withFractionalSeconds],
|
||||||
cursor += 1
|
[.withInternetDateTime]
|
||||||
var scale = 0.1
|
]
|
||||||
while cursor < byteCount {
|
return formats.map { options in
|
||||||
let digit = byte(pointer, cursor)
|
let formatter = ISO8601DateFormatter()
|
||||||
guard digit >= 48, digit <= 57 else { break }
|
formatter.formatOptions = options
|
||||||
fraction += Double(digit - 48) * scale
|
return formatter
|
||||||
scale /= 10
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private func isDatabaseEmpty() -> Bool {
|
||||||
guard let db else { return true }
|
guard let db else { return true }
|
||||||
@@ -901,7 +845,6 @@ final class ClipboardStore {
|
|||||||
|
|
||||||
private func applyPersistence(_ mutation: PersistenceMutation) {
|
private func applyPersistence(_ mutation: PersistenceMutation) {
|
||||||
guard let db else { return }
|
guard let db else { return }
|
||||||
DiagnosticsService.shared.incrementDatabaseMutation()
|
|
||||||
let insertSQL = """
|
let insertSQL = """
|
||||||
INSERT OR REPLACE INTO clipboard_items (
|
INSERT OR REPLACE INTO clipboard_items (
|
||||||
id, kind, display_text, payload, payload_hash,
|
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
|
case keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? {
|
private static let tokenPatterns: [(Reason, NSRegularExpression)] = [
|
||||||
let trimmed = text.clipboardTrimmed
|
(.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)),
|
||||||
guard !trimmed.isEmpty else { return nil }
|
(.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)),
|
||||||
let bytes = Array(trimmed.utf8)
|
(.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 }
|
static func detect(
|
||||||
if containsBearerToken(bytes) { return .bearerToken }
|
_ text: String,
|
||||||
if containsGitHubToken(bytes) { return .githubToken }
|
sourceBundleId: String? = nil,
|
||||||
if containsSlackToken(bytes) { return .slackToken }
|
sourceApp: String? = nil
|
||||||
if containsAWSAccessKey(bytes) { return .awsAccessKey }
|
) -> Reason? {
|
||||||
if containsStripeKey(bytes) { return .stripeKey }
|
let value = text.clipboardTrimmed
|
||||||
if containsOpenAIToken(bytes) { return .openAIToken }
|
guard !value.isEmpty else { return nil }
|
||||||
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 }
|
|
||||||
|
|
||||||
let lowered = trimmed.lowercased()
|
if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") {
|
||||||
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) {
|
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 .keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func containsPrivateKey(_ text: String) -> Bool {
|
private static func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----")
|
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 {
|
private static func looksHighEntropy(_ text: String) -> Bool {
|
||||||
let candidate = text.clipboardTrimmed
|
guard (32...256).contains(text.count),
|
||||||
guard candidate.count >= 32, candidate.count <= 256 else { return false }
|
!text.contains(where: \.isWhitespace) else {
|
||||||
guard !candidate.contains(where: { $0.isWhitespace }) else { return false }
|
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 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0)
|
var characterClasses = 0
|
||||||
return classCount >= 2 && symbolCount > 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
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool {
|
private static func looksLikeOneTimeCode(
|
||||||
let value = text.clipboardTrimmed
|
_ text: String,
|
||||||
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false }
|
sourceBundleId: String?,
|
||||||
|
sourceApp: String?
|
||||||
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased()
|
) -> Bool {
|
||||||
guard !source.isEmpty else { return false }
|
guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else {
|
||||||
return source.contains("auth") ||
|
return false
|
||||||
source.contains("1password") ||
|
}
|
||||||
source.contains("bitwarden") ||
|
let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased()
|
||||||
source.contains("lastpass") ||
|
return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"]
|
||||||
source.contains("keeper") ||
|
.contains(where: source.contains)
|
||||||
source.contains("dashlane")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func containsCreditCard(_ text: String) -> Bool {
|
private static func containsCreditCard(_ text: String) -> Bool {
|
||||||
var digits: [Int] = []
|
var digits: [Int] = []
|
||||||
|
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 char in text {
|
for character in text {
|
||||||
if char.isNumber, let digit = char.wholeNumberValue {
|
if let digit = character.wholeNumberValue {
|
||||||
digits.append(digit)
|
digits.append(digit)
|
||||||
|
} else if (character == " " || character == "-"), !digits.isEmpty {
|
||||||
|
continue
|
||||||
} else {
|
} else {
|
||||||
if isCreditCardGroup(digits) {
|
if isCard(digits) { return true }
|
||||||
return true
|
|
||||||
}
|
|
||||||
digits.removeAll(keepingCapacity: true)
|
digits.removeAll(keepingCapacity: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return isCard(digits)
|
||||||
return isCreditCardGroup(digits)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isCreditCardGroup(_ digits: [Int]) -> Bool {
|
private static func looksLikeSecretAssignment(_ text: String) -> 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 {
|
|
||||||
let keys = [
|
let keys = [
|
||||||
"api_key",
|
"api_key", "apikey", "access_token", "auth_token", "client_secret",
|
||||||
"apikey",
|
"private_token", "refresh_token", "secret_key", "passwd"
|
||||||
"access_token",
|
|
||||||
"auth_token",
|
|
||||||
"client_secret",
|
|
||||||
"private_token",
|
|
||||||
"refresh_token",
|
|
||||||
"secret_key",
|
|
||||||
"passwd"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
guard let range = lowered.range(of: key) else { continue }
|
guard let range = text.range(of: key) else { continue }
|
||||||
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace })
|
let suffix = text[range.upperBound...].drop(while: \.isWhitespace)
|
||||||
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue }
|
guard suffix.first == "=" || suffix.first == ":" else { continue }
|
||||||
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" })
|
let value = suffix.dropFirst().drop {
|
||||||
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count
|
$0.isWhitespace || $0 == "\"" || $0 == "'"
|
||||||
if valueLength >= 8 {
|
}
|
||||||
|
if value.prefix(while: {
|
||||||
|
!$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != ","
|
||||||
|
}).count >= 8 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
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
|
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
|
||||||
)
|
)
|
||||||
|
|
||||||
static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] {
|
|
||||||
[openShortcut, stackCaptureShortcut]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func osStatusMessage(_ status: OSStatus) -> String {
|
private func osStatusMessage(_ status: OSStatus) -> String {
|
||||||
"OSStatus \(status)"
|
"OSStatus \(status)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import QuickLookUI
|
import QuickLookUI
|
||||||
|
|
||||||
struct ClipboardPanelAnimationProfile {
|
|
||||||
let showDuration: TimeInterval
|
|
||||||
let hideDuration: TimeInterval
|
|
||||||
let reflowDuration: TimeInterval
|
|
||||||
let easing: CAMediaTimingFunctionName
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClipboardPanelReflowPlan {
|
struct ClipboardPanelReflowPlan {
|
||||||
let frame: NSRect
|
let frame: NSRect
|
||||||
let bottomSafeInset: CGFloat
|
let bottomSafeInset: CGFloat
|
||||||
@@ -360,10 +353,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
|
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
|
||||||
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func panelFrames(
|
static func panelFrames(
|
||||||
forScreenFrame screenFrame: CGRect,
|
forScreenFrame screenFrame: CGRect,
|
||||||
visibleFrame: CGRect,
|
visibleFrame: CGRect,
|
||||||
@@ -422,27 +411,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
|
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 {
|
static var panelCollectionBehavior: NSWindow.CollectionBehavior {
|
||||||
[.moveToActiveSpace, .fullScreenAuxiliary, .transient]
|
[.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(
|
static func reflowPlan(
|
||||||
forScreenFrame screenFrame: CGRect,
|
forScreenFrame screenFrame: CGRect,
|
||||||
visibleFrame: CGRect,
|
visibleFrame: CGRect,
|
||||||
@@ -848,47 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
quickLookURL as NSURL?
|
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() {
|
private func installClickMonitor() {
|
||||||
removeClickMonitor()
|
removeClickMonitor()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,18 @@ final class ClipboardPanelViewModel {
|
|||||||
case original
|
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] = [] {
|
private(set) var visibleItems: [ClipboardItem] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
var nextItemByID: [UUID: ClipboardItem] = [:]
|
var nextItemByID: [UUID: ClipboardItem] = [:]
|
||||||
@@ -324,18 +336,6 @@ final class ClipboardPanelViewModel {
|
|||||||
var onStackChanged: (() -> Void)?
|
var onStackChanged: (() -> Void)?
|
||||||
var onCaptureStatusChanged: (() -> 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(
|
init(
|
||||||
store: ClipboardStore,
|
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? {
|
var selectedItem: ClipboardItem? {
|
||||||
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
|
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
|
||||||
@@ -445,10 +431,6 @@ final class ClipboardPanelViewModel {
|
|||||||
stackItemIDs.count
|
stackItemIDs.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var stackTitle: String {
|
|
||||||
"Stack"
|
|
||||||
}
|
|
||||||
|
|
||||||
var collectionNames: [String] {
|
var collectionNames: [String] {
|
||||||
if let collectionNamesCache {
|
if let collectionNamesCache {
|
||||||
return collectionNamesCache
|
return collectionNamesCache
|
||||||
@@ -473,14 +455,6 @@ final class ClipboardPanelViewModel {
|
|||||||
return names
|
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 {
|
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
|
||||||
collectionCountSummary().count(for: sortMode)
|
collectionCountSummary().count(for: sortMode)
|
||||||
}
|
}
|
||||||
@@ -505,9 +479,6 @@ final class ClipboardPanelViewModel {
|
|||||||
for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
|
for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
|
||||||
collectionCounts[collectionKey] = indexedItems.count
|
collectionCounts[collectionKey] = indexedItems.count
|
||||||
}
|
}
|
||||||
#if DEBUG
|
|
||||||
debugCollectionCountIndexedLookupCount += 1
|
|
||||||
#endif
|
|
||||||
} else {
|
} else {
|
||||||
for indexedItem in indexedItemsMatchingSearch(query) {
|
for indexedItem in indexedItemsMatchingSearch(query) {
|
||||||
let item = indexedItem.item
|
let item = indexedItem.item
|
||||||
@@ -516,9 +487,6 @@ final class ClipboardPanelViewModel {
|
|||||||
collectionCounts[collectionName.lowercased(), default: 0] += 1
|
collectionCounts[collectionName.lowercased(), default: 0] += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if DEBUG
|
|
||||||
debugCollectionCountFullScanCount += 1
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let summary = ClipboardCollectionCountSummary(
|
let summary = ClipboardCollectionCountSummary(
|
||||||
@@ -673,83 +641,35 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pasteSelected() {
|
func pasteSelected() {
|
||||||
if selectedItemCount > 1 {
|
performSelectedTransfer(.paste)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteSelectedPlainText() {
|
func pasteSelectedPlainText() {
|
||||||
if selectedItemCount > 1 {
|
performSelectedTransfer(.paste, asPlainText: true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteItem(at index: Int) {
|
func pasteItem(at index: Int) {
|
||||||
guard index >= 0 && index < visibleItems.count else { return }
|
guard visibleItems.indices.contains(index) else { return }
|
||||||
selectItem(at: index)
|
selectItem(at: index)
|
||||||
pasteSelected()
|
pasteSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteItemPlainText(at index: Int) {
|
func pasteItemPlainText(at index: Int) {
|
||||||
guard index >= 0 && index < visibleItems.count else { return }
|
guard visibleItems.indices.contains(index) else { return }
|
||||||
selectItem(at: index)
|
selectItem(at: index)
|
||||||
pasteSelectedPlainText()
|
pasteSelectedPlainText()
|
||||||
}
|
}
|
||||||
|
|
||||||
func copySelected() {
|
func copySelected() {
|
||||||
if selectedItemCount > 1 {
|
performSelectedTransfer(.copy)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copySelectedPlainText() {
|
func copySelectedPlainText() {
|
||||||
if selectedItemCount > 1 {
|
performSelectedTransfer(.copy, asPlainText: true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func isItemStacked(at index: Int) -> Bool {
|
func isItemStacked(at index: Int) -> Bool {
|
||||||
guard index >= 0 && index < visibleItems.count else { return false }
|
guard index >= 0 && index < visibleItems.count else { return false }
|
||||||
return stackItemIDSet.contains(visibleItems[index].id)
|
return stackItemIDSet.contains(visibleItems[index].id)
|
||||||
@@ -830,11 +750,6 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearStackSelection() {
|
|
||||||
guard isStackFilterSelected else { return }
|
|
||||||
isStackFilterSelected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearStack() {
|
func clearStack() {
|
||||||
guard !stackItemIDs.isEmpty else {
|
guard !stackItemIDs.isEmpty else {
|
||||||
statusMessage = "Stack is empty"
|
statusMessage = "Stack is empty"
|
||||||
@@ -846,51 +761,23 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyNextStackItem() {
|
func copyNextStackItem() {
|
||||||
guard let item = nextStackItem() else {
|
performNextStackTransfer(.copy)
|
||||||
statusMessage = "Stack is empty"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.copy(item)
|
|
||||||
handleStackActionResult(result, item: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteNextStackItem() {
|
func pasteNextStackItem() {
|
||||||
guard let item = nextStackItem() else {
|
performNextStackTransfer(.paste)
|
||||||
statusMessage = "Stack is empty"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
|
|
||||||
if case .pasted = result {
|
|
||||||
willPasteToTarget()
|
|
||||||
}
|
|
||||||
handleStackActionResult(result, item: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyStackAsText() {
|
func copyStackAsText() {
|
||||||
guard let package = stackPlainTextPackage() else {
|
performStackTextTransfer(.copy)
|
||||||
statusMessage = "Stack has no text to copy"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.copyPlainText(package.text)
|
|
||||||
handleStackPlainTextActionResult(result, items: package.items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteStackAsText() {
|
func pasteStackAsText() {
|
||||||
guard let package = stackPlainTextPackage() else {
|
performStackTextTransfer(.paste)
|
||||||
statusMessage = "Stack has no text to paste"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
|
|
||||||
if case .pastedPlainText = result {
|
|
||||||
willPasteToTarget()
|
|
||||||
}
|
|
||||||
handleStackPlainTextActionResult(result, items: package.items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func addSelectedItemsToStack() {
|
func addSelectedItemsToStack() {
|
||||||
pruneStackItems()
|
pruneStackItems()
|
||||||
let selectedItems = selectedItemsInSelectionOrder()
|
let selectedItems = selectedItemsInSelectionOrder()
|
||||||
@@ -912,54 +799,16 @@ final class ClipboardPanelViewModel {
|
|||||||
statusMessage = "Added \(newIDs.count) selected \(noun) to Stack"
|
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() {
|
func copySelectedItemsAsText() {
|
||||||
guard let package = selectedPlainTextPackage() else {
|
performSelectedTransfer(.copy, asPlainText: true, forceGroup: true)
|
||||||
statusMessage = "Selection has no text to copy"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.copyPlainText(package.text)
|
|
||||||
handleSelectedPlainTextActionResult(result, items: package.items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pasteSelectedItemsAsText() {
|
func pasteSelectedItemsAsText() {
|
||||||
guard let package = selectedPlainTextPackage() else {
|
performSelectedTransfer(.paste, asPlainText: true, forceGroup: true)
|
||||||
statusMessage = "Selection has no text to paste"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
|
|
||||||
if case .pastedPlainText = result {
|
|
||||||
willPasteToTarget()
|
|
||||||
}
|
|
||||||
handleSelectedPlainTextActionResult(result, items: package.items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
|
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
|
||||||
guard index >= 0 && index < visibleItems.count else { return [] }
|
guard index >= 0 && index < visibleItems.count else { return [] }
|
||||||
return pasteService.pasteboardWriters(for: visibleItems[index])
|
return pasteService.pasteboardWriters(for: visibleItems[index])
|
||||||
@@ -970,13 +819,6 @@ final class ClipboardPanelViewModel {
|
|||||||
return item.payload
|
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? {
|
func editableTitleForSelected() -> String? {
|
||||||
guard let item = selectedItem else { return nil }
|
guard let item = selectedItem else { return nil }
|
||||||
return item.customTitle ?? ""
|
return item.customTitle ?? ""
|
||||||
@@ -1540,10 +1382,6 @@ final class ClipboardPanelViewModel {
|
|||||||
statusMessage = "Deleted \(normalizedName)"
|
statusMessage = "Deleted \(normalizedName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSearch() {
|
|
||||||
searchText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func showSelectedInClipboard() {
|
func showSelectedInClipboard() {
|
||||||
guard canShowSelectedInClipboard, let item = selectedItem else { return }
|
guard canShowSelectedInClipboard, let item = selectedItem else { return }
|
||||||
selectedItemID = item.id
|
selectedItemID = item.id
|
||||||
@@ -1762,156 +1600,198 @@ 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] {
|
private func selectedItemsInSelectionOrder() -> [ClipboardItem] {
|
||||||
let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] }
|
let items = selectedItemIDs.compactMap { visibleItemByID[$0] }
|
||||||
if !selectedItems.isEmpty {
|
return items.isEmpty ? selectedItem.map { [$0] } ?? [] : items
|
||||||
return selectedItems
|
|
||||||
}
|
|
||||||
return selectedItem.map { [$0] } ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nextStackItem() -> ClipboardItem? {
|
private func nextStackItem() -> ClipboardItem? {
|
||||||
pruneStackItems()
|
pruneStackItems()
|
||||||
guard let id = stackItemIDs.first else { return nil }
|
return stackItemIDs.first.flatMap { itemByID[$0] }
|
||||||
return itemByID[id]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
private func plainTextPackage(for items: [ClipboardItem]) -> (text: String, items: [ClipboardItem])? {
|
||||||
pruneStackItems()
|
let pairs = items.compactMap { item -> (ClipboardItem, String)? in
|
||||||
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
|
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed,
|
||||||
guard let item = itemByID[id],
|
|
||||||
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
|
|
||||||
!text.isEmpty else {
|
!text.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (item, text)
|
return (item, text)
|
||||||
}
|
}
|
||||||
guard !pairs.isEmpty else { return nil }
|
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])? {
|
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
||||||
let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in
|
pruneStackItems()
|
||||||
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else {
|
return plainTextPackage(for: stackItemIDs.compactMap { itemByID[$0] })
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
return (item, text)
|
completeTransfer(
|
||||||
}
|
plainTextTransfer(intent, value: package.text),
|
||||||
guard !pairs.isEmpty else { return nil }
|
items: package.items,
|
||||||
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
|
context: .selected
|
||||||
}
|
)
|
||||||
|
|
||||||
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
|
|
||||||
if case .failed(let message) = result {
|
|
||||||
statusMessage = message
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeStackItem(item.id)
|
guard let item = items.first else { return }
|
||||||
store.markUsed(item.id)
|
let result = asPlainText
|
||||||
selectedItemID = item.id
|
? plainTextTransfer(intent, item: item)
|
||||||
switch result {
|
: originalTransfer(intent, items: items)
|
||||||
case .copiedNeedsPermission:
|
completeTransfer(result, items: items, context: isGroup ? .selected : .single)
|
||||||
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]) {
|
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 {
|
if case .failed(let message) = result {
|
||||||
statusMessage = message
|
statusMessage = message
|
||||||
|
if case .single = context {
|
||||||
|
settings.setPasteStatus(message: message)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for item in items {
|
if case .pasted = result {
|
||||||
store.markUsed(item.id)
|
willPasteToTarget()
|
||||||
consumeStackItem(item.id, refreshActiveStackFilter: false)
|
} else if case .pastedPlainText = result {
|
||||||
|
willPasteToTarget()
|
||||||
}
|
}
|
||||||
if stackItemIDs.isEmpty {
|
|
||||||
isStackFilterSelected = false
|
switch context {
|
||||||
} else if isStackFilterSelected {
|
case .stackItem:
|
||||||
recomputeVisibleItems()
|
if let item = items.first {
|
||||||
|
consumeStackItem(item.id)
|
||||||
|
store.markUsed(item.id)
|
||||||
|
}
|
||||||
|
case .stackText:
|
||||||
|
for item in items {
|
||||||
|
store.markUsed(item.id)
|
||||||
|
consumeStackItem(item.id, refreshActiveStackFilter: false)
|
||||||
|
}
|
||||||
|
if stackItemIDs.isEmpty {
|
||||||
|
isStackFilterSelected = false
|
||||||
|
} else if isStackFilterSelected {
|
||||||
|
recomputeVisibleItems()
|
||||||
|
}
|
||||||
|
case .single, .selected:
|
||||||
|
for item in items {
|
||||||
|
store.markUsed(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedItemID = items.first?.id
|
selectedItemID = items.first?.id
|
||||||
let noun = items.count == 1 ? "clip" : "clips"
|
statusMessage = transferStatus(result, count: items.count, context: context)
|
||||||
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)
|
settings.setPasteStatus(message: statusMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
private func transferStatus(
|
||||||
if case .failed(let message) = result {
|
_ result: PasteActionService.PasteActionResult,
|
||||||
statusMessage = message
|
count: Int,
|
||||||
return
|
context: TransferContext
|
||||||
}
|
) -> String {
|
||||||
|
let noun = count == 1 ? "clip" : "clips"
|
||||||
for item in items {
|
switch (context, result) {
|
||||||
store.markUsed(item.id)
|
case (.single, _):
|
||||||
}
|
return result.message
|
||||||
selectedItemID = items.first?.id
|
case (.stackItem, .pasted):
|
||||||
let noun = items.count == 1 ? "clip" : "clips"
|
return "Pasted from Stack"
|
||||||
switch result {
|
case (.stackItem, .copied):
|
||||||
case .pasted:
|
return "Copied from Stack"
|
||||||
statusMessage = "Pasted \(items.count) selected \(noun)"
|
case (.stackItem, .copiedNeedsPermission):
|
||||||
case .copiedNeedsPermission:
|
return "Copied from Stack. Grant Accessibility access to paste automatically."
|
||||||
statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically."
|
case (.selected, .pasted):
|
||||||
case .copied:
|
return "Pasted \(count) selected \(noun)"
|
||||||
statusMessage = "Copied \(items.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:
|
default:
|
||||||
statusMessage = result.message
|
return result.message
|
||||||
}
|
}
|
||||||
settings.setPasteStatus(message: statusMessage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
|
||||||
if case .failed(let message) = result {
|
|
||||||
statusMessage = message
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) {
|
||||||
guard let index = stackItemIDs.firstIndex(of: id) else { return }
|
guard let index = stackItemIDs.firstIndex(of: id) else { return }
|
||||||
@@ -1925,9 +1805,6 @@ final class ClipboardPanelViewModel {
|
|||||||
guard stackItemsNeedPruning else { return }
|
guard stackItemsNeedPruning else { return }
|
||||||
stackItemsNeedPruning = false
|
stackItemsNeedPruning = false
|
||||||
guard !stackItemIDs.isEmpty else { return }
|
guard !stackItemIDs.isEmpty else { return }
|
||||||
#if DEBUG
|
|
||||||
debugStackPruneScanCount += 1
|
|
||||||
#endif
|
|
||||||
let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
|
let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
|
||||||
if pruned != stackItemIDs {
|
if pruned != stackItemIDs {
|
||||||
stackItemIDs = pruned
|
stackItemIDs = pruned
|
||||||
@@ -1960,9 +1837,6 @@ final class ClipboardPanelViewModel {
|
|||||||
|
|
||||||
private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] {
|
private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] {
|
||||||
if let searchMatchCache, searchMatchCache.query == query {
|
if let searchMatchCache, searchMatchCache.query == query {
|
||||||
#if DEBUG
|
|
||||||
debugSearchMatchCacheHitCount += 1
|
|
||||||
#endif
|
|
||||||
return searchMatchCache.indexedItems
|
return searchMatchCache.indexedItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1975,9 +1849,6 @@ final class ClipboardPanelViewModel {
|
|||||||
var matches: [IndexedClipboardItem] = []
|
var matches: [IndexedClipboardItem] = []
|
||||||
matches.reserveCapacity(allIndexedItems.count)
|
matches.reserveCapacity(allIndexedItems.count)
|
||||||
for indexedItem in allIndexedItems {
|
for indexedItem in allIndexedItems {
|
||||||
#if DEBUG
|
|
||||||
debugSearchItemEvaluationCount += 1
|
|
||||||
#endif
|
|
||||||
if matchesSearchQuery(indexedItem.item, query: parsedQuery) {
|
if matchesSearchQuery(indexedItem.item, query: parsedQuery) {
|
||||||
matches.append(indexedItem)
|
matches.append(indexedItem)
|
||||||
}
|
}
|
||||||
@@ -2019,9 +1890,6 @@ final class ClipboardPanelViewModel {
|
|||||||
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
|
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
|
||||||
)
|
)
|
||||||
computed = indexedItems.map(\.item)
|
computed = indexedItems.map(\.item)
|
||||||
#if DEBUG
|
|
||||||
debugVisibleItemsIndexedLookupCount += 1
|
|
||||||
#endif
|
|
||||||
} else {
|
} else {
|
||||||
computed = filterAndSortVisibleItems(
|
computed = filterAndSortVisibleItems(
|
||||||
indexedItemsMatchingSearch(query),
|
indexedItemsMatchingSearch(query),
|
||||||
@@ -2029,9 +1897,6 @@ final class ClipboardPanelViewModel {
|
|||||||
collectionName: collectionName,
|
collectionName: collectionName,
|
||||||
categoryFilters: categoryFilters
|
categoryFilters: categoryFilters
|
||||||
)
|
)
|
||||||
#if DEBUG
|
|
||||||
debugVisibleItemsFullScanCount += 1
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
if visibleItemsCache.count > 24 {
|
if visibleItemsCache.count > 24 {
|
||||||
visibleItemsCache.removeAll(keepingCapacity: true)
|
visibleItemsCache.removeAll(keepingCapacity: true)
|
||||||
@@ -2201,9 +2066,6 @@ final class ClipboardPanelViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
categoryFilterSelectionCache = selection
|
categoryFilterSelectionCache = selection
|
||||||
#if DEBUG
|
|
||||||
debugCategoryFilterSelectionBuildCount += 1
|
|
||||||
#endif
|
|
||||||
return selection
|
return selection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2344,9 +2206,6 @@ final class ClipboardPanelViewModel {
|
|||||||
ocrText: item.ocrText
|
ocrText: item.ocrText
|
||||||
)
|
)
|
||||||
if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint {
|
if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint {
|
||||||
#if DEBUG
|
|
||||||
debugSearchDocumentCacheHitCount += 1
|
|
||||||
#endif
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2368,9 +2227,6 @@ final class ClipboardPanelViewModel {
|
|||||||
collection: item.collectionName.map(normalizedStructuredValue) ?? ""
|
collection: item.collectionName.map(normalizedStructuredValue) ?? ""
|
||||||
)
|
)
|
||||||
searchDocumentsByItemID[item.id] = document
|
searchDocumentsByItemID[item.id] = document
|
||||||
#if DEBUG
|
|
||||||
debugSearchDocumentBuildCount += 1
|
|
||||||
#endif
|
|
||||||
return document
|
return document
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,53 +352,4 @@ final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegat
|
|||||||
decisionHandler(.allow)
|
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
|
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 settingsContentMinimumWidth: CGFloat = 440
|
||||||
static let settingsLabelWidth: CGFloat = 128
|
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 tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]
|
||||||
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
|
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
|
||||||
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
|
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
|
||||||
@@ -82,11 +91,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
private let exportArchiveButton = NSButton()
|
private let exportArchiveButton = NSButton()
|
||||||
private let importArchiveButton = NSButton()
|
private let importArchiveButton = NSButton()
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private var debugFullRefreshCountValue = 0
|
|
||||||
private var debugIgnoredAppsRefreshCountValue = 0
|
|
||||||
private var debugDestructiveActionConfirmationOverride: Bool?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
settings: SettingsModel,
|
settings: SettingsModel,
|
||||||
@@ -180,10 +184,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tabTitle(for item: NSTabViewItem) -> String {
|
|
||||||
item.label.clipboardTrimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scrollContainer(for content: NSView) -> NSView {
|
private func scrollContainer(for content: NSView) -> NSView {
|
||||||
let scrollView = NSScrollView()
|
let scrollView = NSScrollView()
|
||||||
scrollView.hasVerticalScroller = true
|
scrollView.hasVerticalScroller = true
|
||||||
@@ -216,26 +216,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
|
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
|
||||||
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
|
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
|
||||||
historyStepper.increment = 25
|
historyStepper.increment = 25
|
||||||
historyStepper.target = self
|
bind(historyStepper, to: .historyLength)
|
||||||
historyStepper.action = #selector(historyLengthChanged)
|
|
||||||
historyStepper.setAccessibilityLabel("History length")
|
historyStepper.setAccessibilityLabel("History length")
|
||||||
configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged))
|
configurePopup(historyRetentionPopup, command: .historyRetention)
|
||||||
historyRetentionPopup.setAccessibilityLabel("Keep history")
|
historyRetentionPopup.setAccessibilityLabel("Keep history")
|
||||||
for retention in HistoryRetention.allCases {
|
for retention in HistoryRetention.allCases {
|
||||||
addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup)
|
addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged))
|
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", command: .pruneDuplicates)
|
||||||
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged))
|
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", command: .keepFirstImage)
|
||||||
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged))
|
configurePopup(defaultSortPopup, command: .defaultSort)
|
||||||
defaultSortPopup.setAccessibilityLabel("Default sort")
|
defaultSortPopup.setAccessibilityLabel("Default sort")
|
||||||
for mode in ClipboardSortMode.allCases {
|
for mode in ClipboardSortMode.allCases {
|
||||||
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
|
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
|
||||||
}
|
}
|
||||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
|
configureCheckbox(launchAtLoginButton, title: "Launch at login", command: .launchAtLogin)
|
||||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
|
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", command: .showMenuBarIcon)
|
||||||
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged))
|
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", command: .showDockIcon)
|
||||||
configurePopup(panelSidePopup, action: #selector(panelSideChanged))
|
configurePopup(panelSidePopup, command: .panelSide)
|
||||||
panelSidePopup.setAccessibilityLabel("Shelf side")
|
panelSidePopup.setAccessibilityLabel("Shelf side")
|
||||||
for side in ClipboardPanelSide.allCases {
|
for side in ClipboardPanelSide.allCases {
|
||||||
addPopupItem(side.title, side.rawValue, to: panelSidePopup)
|
addPopupItem(side.title, side.rawValue, to: panelSidePopup)
|
||||||
@@ -278,9 +277,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func captureSettingsView() -> NSView {
|
private func captureSettingsView() -> NSView {
|
||||||
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged))
|
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", command: .pauseCapture)
|
||||||
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged))
|
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", command: .excludeSensitive)
|
||||||
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged))
|
configureCheckbox(includeImageTextButton, title: "Search in image labels", command: .includeImageText)
|
||||||
configureStatusLabel(captureStatusLabel)
|
configureStatusLabel(captureStatusLabel)
|
||||||
|
|
||||||
let allowedRows = [
|
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 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 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.")
|
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(clearHistoryOnQuitButton, title: "Clear history on quit", command: .clearHistoryOnQuit)
|
||||||
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", action: #selector(hideFromScreenCaptureChanged))
|
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", command: .hideFromScreenCapture)
|
||||||
configureStatusLabel(accessibilityStatusLabel)
|
configureStatusLabel(accessibilityStatusLabel)
|
||||||
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess))
|
let requestButton = button("Open Accessibility Settings", .requestAccessibility)
|
||||||
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus))
|
let refreshButton = button("Refresh Permission Status", .refreshAccessibility)
|
||||||
configureStatusLabel(pasteStatusLabel)
|
configureStatusLabel(pasteStatusLabel)
|
||||||
|
|
||||||
return page([
|
return page([
|
||||||
@@ -361,7 +360,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performanceSettingsView() -> NSView {
|
private func performanceSettingsView() -> NSView {
|
||||||
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged))
|
configurePopup(pollProfilePopup, command: .pollProfile)
|
||||||
pollProfilePopup.setAccessibilityLabel("Polling profile")
|
pollProfilePopup.setAccessibilityLabel("Polling profile")
|
||||||
for profile in AppConfiguration.PollProfile.allCases {
|
for profile in AppConfiguration.PollProfile.allCases {
|
||||||
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
|
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.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024
|
||||||
cacheSlider.numberOfTickMarks = 9
|
cacheSlider.numberOfTickMarks = 9
|
||||||
cacheSlider.allowsTickMarkValuesOnly = true
|
cacheSlider.allowsTickMarkValuesOnly = true
|
||||||
cacheSlider.target = self
|
bind(cacheSlider, to: .cacheLimit)
|
||||||
cacheSlider.action = #selector(cacheLimitChanged)
|
|
||||||
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
|
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
|
||||||
configureStatusLabel(cacheLabel)
|
configureStatusLabel(cacheLabel)
|
||||||
|
|
||||||
@@ -389,12 +387,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
private func dataSettingsView() -> NSView {
|
private func dataSettingsView() -> NSView {
|
||||||
configureStatusLabel(dataStatusLabel)
|
configureStatusLabel(dataStatusLabel)
|
||||||
configureStatusLabel(cloudSyncStatusLabel)
|
configureStatusLabel(cloudSyncStatusLabel)
|
||||||
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", action: #selector(iCloudSyncChanged))
|
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", command: .iCloudSync)
|
||||||
configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive))
|
configureButton(iCloudSyncNowButton, title: "Sync Now", command: .pushICloudArchive)
|
||||||
configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive))
|
configureButton(iCloudRestoreButton, title: "Restore from iCloud", command: .pullICloudArchive)
|
||||||
configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile))
|
configureButton(iCloudRevealButton, title: "Reveal Sync File", command: .revealICloudFile)
|
||||||
configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive))
|
configureButton(exportArchiveButton, title: "Export Archive...", command: .exportArchive)
|
||||||
configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive))
|
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 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.")
|
let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.")
|
||||||
return page([
|
return page([
|
||||||
@@ -416,9 +414,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
section("Data", [
|
section("Data", [
|
||||||
button("Open History Folder", #selector(openHistoryFolder)),
|
button("Open History Folder", .openHistoryFolder),
|
||||||
button("Clear Clipboard History", #selector(clearClipboardHistory)),
|
button("Clear Clipboard History", .clearHistory),
|
||||||
button("Clear Thumbnail Cache", #selector(clearThumbnailCache)),
|
button("Clear Thumbnail Cache", .clearCache),
|
||||||
dataStatusLabel
|
dataStatusLabel
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@@ -472,20 +470,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
private func button(_ title: String, _ action: Selector) -> NSButton {
|
private func button(_ title: String, _ command: ControlCommand) -> NSButton {
|
||||||
let control = NSButton()
|
let control = NSButton()
|
||||||
configureButton(control, title: title, action: action)
|
configureButton(control, title: title, command: command)
|
||||||
return control
|
return control
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureButton(_ control: NSButton, title: String, action: Selector) {
|
private func configureButton(_ control: NSButton, title: String, command: ControlCommand) {
|
||||||
control.title = title
|
control.title = title
|
||||||
control.target = self
|
bind(control, to: command)
|
||||||
control.action = action
|
|
||||||
control.bezelStyle = .rounded
|
control.bezelStyle = .rounded
|
||||||
control.setAccessibilityLabel(title)
|
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) {
|
private func configureCheckbox(_ control: NSButton, title: String, action: Selector) {
|
||||||
control.setButtonType(.switch)
|
control.setButtonType(.switch)
|
||||||
control.title = title
|
control.title = title
|
||||||
@@ -504,10 +506,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configurePopup(_ popup: NSPopUpButton, action: Selector) {
|
private func configurePopup(_ popup: NSPopUpButton, command: ControlCommand) {
|
||||||
popup.removeAllItems()
|
popup.removeAllItems()
|
||||||
popup.target = self
|
bind(popup, to: command)
|
||||||
popup.action = action
|
}
|
||||||
|
|
||||||
|
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) {
|
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) {
|
private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) {
|
||||||
#if DEBUG
|
|
||||||
debugFullRefreshCountValue += 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
refreshHistoryLimitControls()
|
refreshHistoryLimitControls()
|
||||||
refreshHistoryRetentionControl()
|
refreshHistoryRetentionControl()
|
||||||
@@ -969,9 +973,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func refreshIgnoredAppsTextView(force: Bool = false) {
|
private func refreshIgnoredAppsTextView(force: Bool = false) {
|
||||||
#if DEBUG
|
|
||||||
debugIgnoredAppsRefreshCountValue += 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard force || !isEditingIgnoredApps else { return }
|
guard force || !isEditingIgnoredApps else { return }
|
||||||
|
|
||||||
@@ -1048,67 +1049,98 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func historyLengthChanged() {
|
@objc private func performControlCommand(_ sender: NSControl) {
|
||||||
settings.maxHistoryItems = historyStepper.integerValue
|
guard let command = ControlCommand(rawValue: sender.tag) else { return }
|
||||||
refreshHistoryLimitControls()
|
switch command {
|
||||||
}
|
case .historyLength:
|
||||||
|
settings.maxHistoryItems = historyStepper.integerValue
|
||||||
@objc private func historyRetentionChanged() {
|
refreshHistoryLimitControls()
|
||||||
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
|
case .historyRetention:
|
||||||
let retention = HistoryRetention(rawValue: rawValue) {
|
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
|
||||||
settings.historyRetention = retention
|
let retention = HistoryRetention(rawValue: rawValue) {
|
||||||
}
|
settings.historyRetention = retention
|
||||||
}
|
}
|
||||||
|
case .pruneDuplicates:
|
||||||
@objc private func pruneDuplicatesChanged() {
|
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
|
||||||
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
|
case .keepFirstImage:
|
||||||
}
|
settings.keepFirstImage = keepFirstImageButton.state == .on
|
||||||
|
case .defaultSort:
|
||||||
@objc private func keepFirstImageChanged() {
|
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
|
||||||
settings.keepFirstImage = keepFirstImageButton.state == .on
|
let mode = ClipboardSortMode(rawValue: rawValue) {
|
||||||
}
|
settings.defaultSortMode = mode
|
||||||
|
}
|
||||||
@objc private func defaultSortChanged() {
|
case .launchAtLogin:
|
||||||
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
|
let enabled = launchAtLoginButton.state == .on
|
||||||
let mode = ClipboardSortMode(rawValue: rawValue) {
|
settings.launchAtLogin = enabled
|
||||||
settings.defaultSortMode = mode
|
if !enabled {
|
||||||
}
|
settings.setLaunchAtLoginStatus(message: "")
|
||||||
}
|
}
|
||||||
|
refreshLaunchAtLoginControls()
|
||||||
@objc private func launchAtLoginChanged() {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||||
let enabled = launchAtLoginButton.state == .on
|
self?.refreshLaunchAtLoginControls()
|
||||||
settings.launchAtLogin = enabled
|
}
|
||||||
if !enabled {
|
case .showMenuBarIcon:
|
||||||
settings.setLaunchAtLoginStatus(message: "")
|
let shouldShow = showMenuBarIconButton.state == .on
|
||||||
}
|
settings.showMenuBarIcon = shouldShow
|
||||||
refreshLaunchAtLoginControls()
|
if !shouldShow && !settings.showDockIcon {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
settings.showDockIcon = true
|
||||||
self?.refreshLaunchAtLoginControls()
|
}
|
||||||
}
|
refreshVisibilityControls()
|
||||||
}
|
case .showDockIcon:
|
||||||
|
let shouldShow = showDockIconButton.state == .on
|
||||||
@objc private func showMenuBarIconChanged() {
|
settings.showDockIcon = shouldShow
|
||||||
let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on
|
if !shouldShow && !settings.showMenuBarIcon {
|
||||||
settings.showMenuBarIcon = shouldShowMenuBarIcon
|
settings.showMenuBarIcon = true
|
||||||
if !shouldShowMenuBarIcon && !settings.showDockIcon {
|
}
|
||||||
settings.showDockIcon = true
|
refreshVisibilityControls()
|
||||||
}
|
case .panelSide:
|
||||||
refreshVisibilityControls()
|
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
|
||||||
}
|
let side = ClipboardPanelSide(rawValue: rawValue) {
|
||||||
|
settings.panelSide = side
|
||||||
@objc private func showDockIconChanged() {
|
}
|
||||||
let shouldShowDockIcon = showDockIconButton.state == .on
|
case .pauseCapture:
|
||||||
settings.showDockIcon = shouldShowDockIcon
|
if pauseCaptureButton.state == .on {
|
||||||
if !shouldShowDockIcon && !settings.showMenuBarIcon {
|
settings.pauseCaptureUntil = nil
|
||||||
settings.showMenuBarIcon = true
|
settings.pauseCapture = true
|
||||||
}
|
} else {
|
||||||
refreshVisibilityControls()
|
settings.pauseCapture = false
|
||||||
}
|
settings.pauseCaptureUntil = nil
|
||||||
|
}
|
||||||
@objc private func panelSideChanged() {
|
case .excludeSensitive:
|
||||||
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
|
settings.excludeSensitive = excludeSensitiveButton.state == .on
|
||||||
let side = ClipboardPanelSide(rawValue: rawValue) {
|
case .includeImageText:
|
||||||
settings.panelSide = side
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
@objc private func allowedKindChanged(_ sender: NSButton) {
|
||||||
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
|
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
|
||||||
var ignored = settings.ignoredItemKindsRaw
|
var ignored = settings.ignoredItemKindsRaw
|
||||||
@@ -1228,15 +1242,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
commitIgnoredAppsDraftIfNeeded()
|
commitIgnoredAppsDraftIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func clearHistoryOnQuitChanged() {
|
private func requestAccessibilityAccess() {
|
||||||
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func hideFromScreenCaptureChanged() {
|
|
||||||
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func requestAccessibilityAccess() {
|
|
||||||
_ = AccessibilityPermissionService.requestPromptIfNeeded()
|
_ = AccessibilityPermissionService.requestPromptIfNeeded()
|
||||||
if !AccessibilityPermissionService.isTrusted {
|
if !AccessibilityPermissionService.isTrusted {
|
||||||
AccessibilityPermissionService.openSystemSettings()
|
AccessibilityPermissionService.openSystemSettings()
|
||||||
@@ -1247,7 +1253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
refreshAccessibilityPermissionStatus()
|
refreshAccessibilityPermissionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshAccessibilityPermissionStatus() {
|
private func refreshAccessibilityPermissionStatus() {
|
||||||
settings.setAccessibilityPermissionStatus(
|
settings.setAccessibilityPermissionStatus(
|
||||||
message: AccessibilityPermissionService.isTrusted
|
message: AccessibilityPermissionService.isTrusted
|
||||||
? ""
|
? ""
|
||||||
@@ -1256,34 +1262,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
refreshAccessibilityPermissionStatusLabel()
|
refreshAccessibilityPermissionStatusLabel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func pollProfileChanged() {
|
private func openHistoryFolder() {
|
||||||
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() {
|
|
||||||
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
|
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func exportClipboardArchive() {
|
private func exportClipboardArchive() {
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.title = "Export ClipBored Archive"
|
panel.title = "Export ClipBored Archive"
|
||||||
panel.nameFieldStringValue = defaultArchiveFileName()
|
panel.nameFieldStringValue = defaultArchiveFileName()
|
||||||
@@ -1303,7 +1286,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func importClipboardArchive() {
|
private func importClipboardArchive() {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.title = "Import ClipBored Archive"
|
panel.title = "Import ClipBored Archive"
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
@@ -1329,7 +1312,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func pushICloudSyncArchive() {
|
private func pushICloudSyncArchive() {
|
||||||
guard settings.iCloudSyncEnabled else {
|
guard settings.iCloudSyncEnabled else {
|
||||||
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.")
|
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.")
|
||||||
return
|
return
|
||||||
@@ -1352,7 +1335,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func pullICloudSyncArchive() {
|
private func pullICloudSyncArchive() {
|
||||||
guard settings.iCloudSyncEnabled else {
|
guard settings.iCloudSyncEnabled else {
|
||||||
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.")
|
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.")
|
||||||
return
|
return
|
||||||
@@ -1400,7 +1383,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func revealICloudSyncFile() {
|
private func revealICloudSyncFile() {
|
||||||
do {
|
do {
|
||||||
let url = try cloudSyncService.syncArchiveURL()
|
let url = try cloudSyncService.syncArchiveURL()
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
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(
|
guard confirmDestructiveAction(
|
||||||
title: "Clear Clipboard History?",
|
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.",
|
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.")
|
setDataStatus("Cleared clipboard history.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func clearThumbnailCache() {
|
private func clearThumbnailCache() {
|
||||||
guard confirmDestructiveAction(
|
guard confirmDestructiveAction(
|
||||||
title: "Clear Thumbnail Cache?",
|
title: "Clear Thumbnail Cache?",
|
||||||
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
|
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 {
|
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
|
||||||
#if DEBUG
|
|
||||||
if let override = debugDestructiveActionConfirmationOverride {
|
|
||||||
return override
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = title
|
alert.messageText = title
|
||||||
@@ -1551,447 +1529,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
|
|||||||
return nil
|
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 {
|
private struct ShortcutControlSet {
|
||||||
|
|||||||
@@ -4,21 +4,6 @@ import XCTest
|
|||||||
@testable import ClipBored
|
@testable import ClipBored
|
||||||
|
|
||||||
final class ClipboardPanelControllerTests: XCTestCase {
|
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() {
|
func testPanelFrameUsesRightSideShelfByDefault() {
|
||||||
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||||
@@ -31,7 +16,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() {
|
func testOpenScreenSelectionPrefersExplicitThenPointerScreen() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
ClipboardPanelController.selectedOpenScreen(
|
ClipboardPanelController.selectedOpenScreen(
|
||||||
explicit: "status-item-screen",
|
explicit: "status-item-screen",
|
||||||
@@ -41,9 +26,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
),
|
),
|
||||||
"status-item-screen"
|
"status-item-screen"
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() {
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
ClipboardPanelController.selectedOpenScreen(
|
ClipboardPanelController.selectedOpenScreen(
|
||||||
explicit: Optional<String>.none,
|
explicit: Optional<String>.none,
|
||||||
@@ -141,31 +123,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
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() {
|
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
|
||||||
let behavior = ClipboardPanelController.panelCollectionBehavior
|
let behavior = ClipboardPanelController.panelCollectionBehavior
|
||||||
|
|
||||||
@@ -175,69 +132,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertFalse(behavior.contains(.canJoinAllSpaces))
|
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() {
|
func testPanelFrameUsesConfiguredRightSideShelf() {
|
||||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||||
@@ -289,22 +183,20 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(frames.shown.width, 336)
|
XCTAssertEqual(frames.shown.width, 336)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testContentBottomInsetReservesBottomDockSpace() {
|
func testContentBottomInsetHandlesBottomAndSideDock() {
|
||||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||||
|
|
||||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||||
|
|
||||||
XCTAssertEqual(inset, 20)
|
XCTAssertEqual(inset, 20)
|
||||||
}
|
XCTAssertEqual(
|
||||||
|
ClipboardPanelController.contentBottomInset(
|
||||||
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
|
forScreenFrame: screenFrame,
|
||||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
),
|
||||||
|
18
|
||||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(inset, 18)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
|
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
|
||||||
@@ -326,41 +218,38 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCommandNumberShortcutsMapToQuickPasteSlots() {
|
func testCommandNumberShortcutsMapToQuickPasteSlots() {
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
|
(18, .command, 0), (19, .command, 1), (20, .command, 2),
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2)
|
(21, .command, 3), (23, .command, 4), (22, .command, 5),
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3)
|
(26, .command, 6), (28, .command, 7), (25, .command, 8)
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4)
|
], using: ClipboardPanelController.quickPasteIndex)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
|
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8)
|
(18, [.command, .shift], 0),
|
||||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command))
|
(25, [.command, .shift], 8),
|
||||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift]))
|
(18, .command, nil),
|
||||||
|
(18, [.command, .option, .shift], nil)
|
||||||
|
], using: ClipboardPanelController.quickPastePlainTextIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCommandOptionNumberShortcutsMapToCollections() {
|
func testCommandOptionNumberShortcutsMapToCollections() {
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed)
|
(18, [.command, .option], .mostRecent),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text)
|
(19, [.command, .option], .mostUsed),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links)
|
(20, [.command, .option], .text),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images)
|
(21, [.command, .option], .links),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files)
|
(23, [.command, .option], .images),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
|
(22, [.command, .option], .files),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
|
(26, [.command, .option], .pinned),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
|
(28, [.command, .option], .audio),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code)
|
(25, [.command, .option], .colors),
|
||||||
}
|
(29, [.command, .option], .code),
|
||||||
|
(18, [], nil),
|
||||||
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {
|
(18, .command, nil),
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
|
(29, .command, nil)
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command))
|
], using: ClipboardPanelController.collectionShortcutMode)
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
|
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
|
||||||
@@ -372,60 +261,35 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationShortcutsMapToShelfMovement() {
|
func testNavigationShortcutsMapToShelfMovement() {
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last)
|
(115, [], .first), (119, [], .last), (124, [], .next),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next)
|
(121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext)
|
(126, .command, .first), (125, .command, .last),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious)
|
(124, .command, nil), (126, [], nil), (125, [], nil),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous)
|
(121, .shift, nil), (35, [], nil)
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: .command), .first)
|
], using: ClipboardPanelController.navigationShortcutAction)
|
||||||
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: []))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectionShortcutsMapToRangeAndSelectAllActions() {
|
func testSelectionShortcutsMapToRangeAndSelectAllActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: .command), .selectAll)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 115, modifiers: .shift), .extendFirst)
|
(0, .command, .selectAll),
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 119, modifiers: .shift), .extendLast)
|
(115, .shift, .extendFirst), (119, .shift, .extendLast),
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: .shift), .extendNext)
|
(124, .shift, .extendNext), (121, .shift, .extendPageNext),
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 121, modifiers: .shift), .extendPageNext)
|
(116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious),
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 116, modifiers: .shift), .extendPagePrevious)
|
(0, [], nil), (0, [.command, .shift], nil),
|
||||||
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 123, modifiers: .shift), .extendPrevious)
|
(124, [], nil), (124, [.command, .shift], nil)
|
||||||
}
|
], using: ClipboardPanelController.selectionShortcutAction)
|
||||||
|
|
||||||
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]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCommandActionShortcutsMapToSelectedClipActions() {
|
func testCommandActionShortcutsMapToSelectedClipActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 14, modifiers: .command), .edit)
|
(8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 3, modifiers: .command), .focusSearch)
|
(45, .command, nil), (5, .command, .showInClipboard),
|
||||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 45, modifiers: .command))
|
(16, .command, .preview), (31, .command, .open), (15, .command, .rename),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)
|
(17, .command, .toggleCapturePause), (6, .command, .undoDelete),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
|
(123, .command, .previousCollection), (124, .command, .nextCollection),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
|
(8, [], nil), (8, [.command, .shift], nil), (9, .command, nil)
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .rename)
|
], using: ClipboardPanelController.commandShortcutAction)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
|
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
|
||||||
@@ -455,67 +319,32 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testModifiedShortcutsMapToPanelActions() {
|
func testModifiedShortcutsMapToPanelActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
|
(36, .shift, .pastePlainText),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .toggleStackCapture)
|
(1, [.command, .shift], .toggleStack),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
|
(8, [.command, .shift], .toggleStackCapture),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection)
|
(9, [.command, .shift], .pastePlainText),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
|
(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() {
|
private func assertShortcutMappings<Value: Equatable>(
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: []))
|
_ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)],
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .shift))
|
using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?,
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command))
|
file: StaticString = #filePath,
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift]))
|
line: UInt = #line
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift]))
|
) {
|
||||||
}
|
for testCase in cases {
|
||||||
|
XCTAssertEqual(
|
||||||
private func makeController(preferredScreen: NSScreen) -> (ClipboardPanelController, ClipboardStore) {
|
mapping(testCase.keyCode, testCase.modifiers),
|
||||||
let settings = makeSettings()
|
testCase.expected,
|
||||||
let encryptionService = ClipboardEncryptionService(keyProvider: { nil })
|
"keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)",
|
||||||
let cacheService = ClipboardCacheService(
|
file: file,
|
||||||
baseURL: makeTempDirectory(),
|
line: line
|
||||||
encryptionService: encryptionService
|
)
|
||||||
)
|
|
||||||
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"])
|
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() {
|
func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
@@ -405,139 +117,64 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(completionMainThreadValues, [true, true])
|
XCTAssertEqual(completionMainThreadValues, [true, true])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
|
func testContentKindsSupportCategoryStructuredAndTextSearch() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let store = makeStore(settings: settings)
|
let store = makeStore(settings: settings)
|
||||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
|
||||||
let color = ClipboardItem(
|
let color = makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .color,
|
kind: .color,
|
||||||
displayText: "#0A84FF",
|
displayText: "#0A84FF",
|
||||||
payload: "#0A84FF",
|
payload: "#0A84FF",
|
||||||
payloadHash: hash("#0A84FF"),
|
timestamp: 100,
|
||||||
createdAt: Date(timeIntervalSince1970: 100),
|
sourceApp: "Design Tool"
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
|
||||||
useCount: 0,
|
|
||||||
sourceApp: "Design Tool",
|
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil
|
|
||||||
)
|
)
|
||||||
let text = ClipboardItem(
|
let code = makeItem(
|
||||||
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(),
|
|
||||||
kind: .code,
|
kind: .code,
|
||||||
displayText: "Swift Snippet",
|
displayText: "Swift Snippet",
|
||||||
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
|
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
|
||||||
payloadHash: hash("swift-snippet"),
|
timestamp: 300,
|
||||||
createdAt: Date(timeIntervalSince1970: 200),
|
sourceApp: "Xcode"
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
|
||||||
useCount: 0,
|
|
||||||
sourceApp: "Xcode",
|
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil
|
|
||||||
)
|
)
|
||||||
let text = ClipboardItem(
|
let video = makeItem(
|
||||||
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(),
|
|
||||||
kind: .video,
|
kind: .video,
|
||||||
displayText: "Video (12 KB)",
|
displayText: "Video (12 KB)",
|
||||||
payload: "/tmp/clip.mp4",
|
payload: "/tmp/clip.mp4",
|
||||||
payloadHash: hash("clip-video"),
|
timestamp: 200,
|
||||||
createdAt: Date(timeIntervalSince1970: 200),
|
sourceApp: "QuickTime Player"
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 200),
|
|
||||||
useCount: 0,
|
|
||||||
sourceApp: "QuickTime Player",
|
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil
|
|
||||||
)
|
)
|
||||||
let image = ClipboardItem(
|
let text = makeItem(
|
||||||
id: UUID(),
|
kind: .text,
|
||||||
|
displayText: "Meeting note",
|
||||||
|
payload: "Meeting note",
|
||||||
|
timestamp: 100,
|
||||||
|
sourceApp: "Notes"
|
||||||
|
)
|
||||||
|
let image = makeItem(
|
||||||
kind: .image,
|
kind: .image,
|
||||||
displayText: "Image",
|
displayText: "Image",
|
||||||
payload: "/tmp/image.png",
|
payload: "/tmp/image.png",
|
||||||
payloadHash: hash("image"),
|
timestamp: 100,
|
||||||
createdAt: Date(timeIntervalSince1970: 100),
|
sourceApp: "Preview"
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
|
||||||
useCount: 0,
|
|
||||||
sourceApp: "Preview",
|
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
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]
|
|
||||||
)
|
)
|
||||||
|
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: items, query: query, sortMode: sortMode).map(\.kind),
|
||||||
|
expectedKinds,
|
||||||
|
"sort=\(sortMode), query=\(query)"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSearchMatchesIndependentTokensCaseInsensitively() {
|
func testSearchMatchesIndependentTokensCaseInsensitively() {
|
||||||
@@ -2547,7 +2184,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
viewModel.searchText = "second"
|
viewModel.searchText = "second"
|
||||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
|
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
|
||||||
|
|
||||||
viewModel.clearSearch()
|
viewModel.searchText = ""
|
||||||
viewModel.sortMode = .text
|
viewModel.sortMode = .text
|
||||||
XCTAssertFalse(viewModel.isStackFilterSelected)
|
XCTAssertFalse(viewModel.isStackFilterSelected)
|
||||||
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
|
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
|
||||||
@@ -2566,7 +2203,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
viewModel.searchText = "draft"
|
viewModel.searchText = "draft"
|
||||||
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
|
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
|
||||||
viewModel.clearSearch()
|
viewModel.searchText = ""
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
|
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
|
||||||
viewModel.updateSelectedText(to: "final launch note")
|
viewModel.updateSelectedText(to: "final launch note")
|
||||||
@@ -2634,8 +2271,8 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
waitForVisibleItems(in: viewModel, count: 2)
|
waitForVisibleItems(in: viewModel, count: 2)
|
||||||
|
|
||||||
XCTAssertNil(viewModel.editableTextForItem(at: 0))
|
|
||||||
viewModel.selectItem(at: 0)
|
viewModel.selectItem(at: 0)
|
||||||
|
XCTAssertNil(viewModel.editableTextForSelected())
|
||||||
viewModel.updateSelectedText(to: "should not apply")
|
viewModel.updateSelectedText(to: "should not apply")
|
||||||
XCTAssertEqual(store.items.first?.payload, file.payload)
|
XCTAssertEqual(store.items.first?.payload, file.payload)
|
||||||
|
|
||||||
@@ -2963,18 +2600,32 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
|
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(
|
ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: kind,
|
||||||
displayText: text,
|
displayText: displayText,
|
||||||
payload: text,
|
payload: payload,
|
||||||
payloadHash: hash(text),
|
payloadHash: hash(payload),
|
||||||
createdAt: createdAt,
|
createdAt: Date(timeIntervalSince1970: timestamp),
|
||||||
lastUsedAt: createdAt,
|
lastUsedAt: Date(timeIntervalSince1970: lastUsedTimestamp ?? timestamp),
|
||||||
useCount: 0,
|
useCount: useCount,
|
||||||
sourceApp: nil,
|
sourceApp: sourceApp,
|
||||||
imagePath: nil,
|
imagePath: nil,
|
||||||
thumbnailPath: nil
|
thumbnailPath: nil,
|
||||||
|
isPinned: isPinned
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3031,88 +2682,56 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
private func makeSampleItems() -> [ClipboardItem] {
|
private func makeSampleItems() -> [ClipboardItem] {
|
||||||
[
|
[
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .text,
|
kind: .text,
|
||||||
displayText: "Project notes",
|
displayText: "Project notes",
|
||||||
payload: "one",
|
payload: "one",
|
||||||
payloadHash: hash("one"),
|
timestamp: 1000,
|
||||||
createdAt: Date(timeIntervalSince1970: 1000),
|
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1000),
|
|
||||||
useCount: 2,
|
useCount: 2,
|
||||||
sourceApp: nil,
|
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: true
|
isPinned: true
|
||||||
),
|
),
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .richText,
|
kind: .richText,
|
||||||
displayText: "Two",
|
displayText: "Two",
|
||||||
payload: "two",
|
payload: "two",
|
||||||
payloadHash: hash("two"),
|
timestamp: 1100,
|
||||||
createdAt: Date(timeIntervalSince1970: 1100),
|
lastUsedTimestamp: 1080,
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1080),
|
|
||||||
useCount: 4,
|
useCount: 4,
|
||||||
sourceApp: "Mail",
|
sourceApp: "Mail"
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: false
|
|
||||||
),
|
),
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .url,
|
kind: .url,
|
||||||
displayText: "Apple",
|
displayText: "Apple",
|
||||||
payload: "https://apple.com",
|
payload: "https://apple.com",
|
||||||
payloadHash: hash("https://apple.com"),
|
timestamp: 1030,
|
||||||
createdAt: Date(timeIntervalSince1970: 1030),
|
lastUsedTimestamp: 1050,
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1050),
|
|
||||||
useCount: 1,
|
useCount: 1,
|
||||||
sourceApp: "Safari",
|
sourceApp: "Safari"
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: false
|
|
||||||
),
|
),
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .file,
|
kind: .file,
|
||||||
displayText: "report.pdf",
|
displayText: "report.pdf",
|
||||||
payload: "/tmp/report.pdf",
|
payload: "/tmp/report.pdf",
|
||||||
payloadHash: hash("/tmp/report.pdf"),
|
timestamp: 1060,
|
||||||
createdAt: Date(timeIntervalSince1970: 1060),
|
lastUsedTimestamp: 1070,
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1070),
|
|
||||||
useCount: 3,
|
useCount: 3,
|
||||||
sourceApp: "Finder",
|
sourceApp: "Finder"
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: false
|
|
||||||
),
|
),
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .audio,
|
kind: .audio,
|
||||||
displayText: "Voice memo",
|
displayText: "Voice memo",
|
||||||
payload: "/tmp/voice.sound",
|
payload: "/tmp/voice.sound",
|
||||||
payloadHash: hash("/tmp/voice.sound"),
|
timestamp: 1040,
|
||||||
createdAt: Date(timeIntervalSince1970: 1040),
|
lastUsedTimestamp: 1060,
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1060),
|
|
||||||
useCount: 2,
|
useCount: 2,
|
||||||
sourceApp: "Voice Memos",
|
sourceApp: "Voice Memos"
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: false
|
|
||||||
),
|
),
|
||||||
ClipboardItem(
|
makeItem(
|
||||||
id: UUID(),
|
|
||||||
kind: .text,
|
kind: .text,
|
||||||
displayText: "Four",
|
displayText: "Four",
|
||||||
payload: "four",
|
payload: "four",
|
||||||
payloadHash: hash("four"),
|
timestamp: 1200,
|
||||||
createdAt: Date(timeIntervalSince1970: 1200),
|
|
||||||
lastUsedAt: Date(timeIntervalSince1970: 1200),
|
|
||||||
useCount: 0,
|
|
||||||
sourceApp: "Notes",
|
sourceApp: "Notes",
|
||||||
imagePath: nil,
|
|
||||||
thumbnailPath: nil,
|
|
||||||
isPinned: true
|
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)
|
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() {
|
func testDetectsCreditCardWithLuhnCheck() {
|
||||||
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
||||||
|
XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard)
|
||||||
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
|
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()
|
manager.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGlobalRegistrationExcludesLocalSettingsShortcut() {
|
|
||||||
let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut)
|
|
||||||
|
|
||||||
XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut])
|
|
||||||
XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
|
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
|
||||||
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)
|
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user