diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fd3feea
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [main, development]
+ pull_request:
+
+jobs:
+ test:
+ name: Build, Test & Lint (macOS)
+ # macOS 26 SDK is required to compile NSGlassEffectView (Liquid Glass).
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest
+
+ - name: Verify toolchain
+ run: swift --version
+
+ - name: Build (warnings are errors)
+ run: swift build -Xswiftc -warnings-as-errors
+
+ - name: Test
+ run: swift test
+
+ - name: Lint
+ run: |
+ brew install swiftlint
+ swiftlint --reporter github-actions-logging
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a7a6ceb..476b687 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,16 +10,20 @@ permissions:
jobs:
build-and-release:
- runs-on: macos-14
+ # macOS 26 SDK is required to compile NSGlassEffectView (Liquid Glass).
+ runs-on: macos-26
steps:
- name: Checkout repository
uses: actions/checkout@v4
- - name: Select Xcode 16
+ - name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
- xcode-version: '16.2'
+ xcode-version: latest
+
+ - name: Verify toolchain
+ run: swift --version
- name: Build and package release
run: |
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..e920c09
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,69 @@
+# clap — SwiftLint configuration.
+# Rules are deliberately conservative: anything enabled here must pass on the
+# current codebase so CI stays green (errors block; warnings stay visible).
+
+included:
+ - Sources
+ - Tests
+ - Package.swift
+
+excluded:
+ - .build
+ - dist
+ - Scripts
+
+opt_in_rules:
+ - empty_count
+ - explicit_init
+ - closure_spacing
+ - first_where
+ - last_where
+ - toggle_bool
+ - contains_over_first_not_nil
+ - sorted_first_last
+
+line_length:
+ warning: 130
+ error: 160
+ ignores_function_declarations: true
+ ignores_comments: true
+
+type_body_length:
+ warning: 400
+ error: 600
+
+function_body_length:
+ warning: 80
+ error: 150
+
+file_length:
+ warning: 900
+ error: 1200
+
+identifier_name:
+ # SQL bind helpers and short math locals use compact names by design.
+ min_length: 1
+ max_length: 60
+ excluded:
+ - id
+ - db
+ - x
+ - y
+
+cyclomatic_complexity:
+ warning: 16
+ error: 25
+
+large_tuple:
+ # Public store APIs intentionally return labeled result tuples.
+ warning: 4
+ error: 5
+
+function_parameter_count:
+ # Row-insert helpers legitimately ship one parameter per column.
+ warning: 11
+ error: 12
+
+nesting:
+ type_level:
+ warning: 3
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a212560..9d3ad94 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -8,15 +8,19 @@ this file.
## Targets
- `ClapCore` (library): SQLite storage, FTS5 search, normalization, hashing,
- dedup, LRU eviction, settings, image file store, stats, doctor checks.
- **No AppKit/SwiftUI imports** (Foundation + CoreGraphics/ImageIO allowed for
- thumbnailing).
+ dedup, LRU eviction, settings, image file store, stats, doctor checks, OCR
+ text extraction, and shared text analysis (color/case/Base64/URL/JWT/epoch).
+ **No AppKit/SwiftUI imports** (Foundation + CoreGraphics/ImageIO +
+ UniformTypeIdentifiers + CryptoKit + Vision allowed — Vision powers the
+ injectable `OCREngine`).
- `ClapApp` (executable): NSApplication accessory app. Pasteboard monitor,
- Carbon global hotkey (Cmd+Shift+V), SwiftUI floating panel (Classic/Media
- tabs), menu bar item, settings window.
-- `clap` (executable, Sources/ClapCLI): subcommand CLI. Hand-rolled argument
- parsing (no external dependencies). May import AppKit only for
- NSPasteboard writes (`clap copy`).
+ Carbon global hotkey (configurable), SwiftUI floating panel (Classic/Media/
+ Shell/Favs tabs), menu bar item, settings window.
+- `ClapCLIKit` (library): all `clap` command logic and output formatting as a
+ unit-testable library. May import AppKit only for NSPasteboard writes
+ (`clap copy`) and NSWorkspace process probing.
+- `clap` (executable, Sources/ClapCLI): thin entry point delegating to
+ ClapCLIKit.
## Data locations
@@ -26,13 +30,13 @@ this file.
- Images: `/images/.` (original data, written atomically).
- Thumbnails: `/thumbnails/.png` (max 400px long edge).
-## Database schema (SQLite, user_version = 1)
+## Database schema (SQLite, user_version = 2)
```sql
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
- type TEXT NOT NULL, -- 'text' | 'image'
- content TEXT, -- normalized text; NULL for images
+ type TEXT NOT NULL, -- 'text' | 'image' | 'shell'
+ content TEXT, -- normalized text / OCR text; NULL for images
image_path TEXT, -- relative path under images/; NULL for text
image_format TEXT, -- 'png','jpeg','tiff',...
content_hash TEXT NOT NULL, -- 64-bit FNV-1a hex for text, SHA256 hex for images
@@ -40,8 +44,10 @@ CREATE TABLE IF NOT EXISTS entries (
last_used_at REAL NOT NULL,
size_bytes INTEGER NOT NULL,
is_pinned INTEGER NOT NULL DEFAULT 0,
+ is_favorite INTEGER NOT NULL DEFAULT 0,
use_count INTEGER NOT NULL DEFAULT 1,
- source_app TEXT -- bundle id of frontmost app at capture, optional
+ source_app TEXT, -- bundle id of frontmost app at capture, optional
+ shortcut TEXT -- snippet abbreviation trigger, e.g. ';email'
);
-- Non-unique: dedup is enforced by lookup-inside-transaction (BEGIN IMMEDIATE
-- serializes writers across processes) with content equality verified, so a
@@ -49,6 +55,8 @@ CREATE TABLE IF NOT EXISTS entries (
CREATE INDEX IF NOT EXISTS idx_entries_hash ON entries(type, content_hash);
CREATE INDEX IF NOT EXISTS idx_entries_lru ON entries(is_pinned, last_used_at);
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type, last_used_at DESC);
+CREATE INDEX IF NOT EXISTS idx_entries_shortcut ON entries(shortcut);
+CREATE INDEX IF NOT EXISTS idx_entries_fav ON entries(is_favorite, last_used_at DESC);
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
content, content='entries', content_rowid='id', tokenize='unicode61'
@@ -63,6 +71,14 @@ CREATE TABLE IF NOT EXISTS stats_counters (
key TEXT PRIMARY KEY, -- e.g. 'events:2026-08-15', 'dups:2026-08-15'
value INTEGER NOT NULL
);
+CREATE TABLE IF NOT EXISTS entry_tags (
+ entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
+ tag TEXT NOT NULL COLLATE NOCASE,
+ created_at REAL NOT NULL,
+ PRIMARY KEY (entry_id, tag)
+);
+CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id);
```
## Config keys (strings in `config` table, typed accessors in Settings)
@@ -126,15 +142,18 @@ public struct StoreStats: Sendable {
/// The single entry point. An actor so all DB access is serialized per process.
/// Multi-process safety comes from SQLite WAL + busy_timeout.
public actor ClipboardStore {
- public init(dataDir: URL? = nil) throws // nil → default/env resolution
+ public init(dataDir: URL? = nil,
+ now: @escaping @Sendable () -> Date = { Date() },
+ ocr: any OCREngine = VisionOCREngine()) throws
public nonisolated let dataDir: URL
// Capture path (fast): normalize → hash → indexed lookup → insert or touch.
// Returns the entry and whether it was a duplicate (touched, not inserted).
+ // OCR runs OUTSIDE the write transaction and off the actor executor.
@discardableResult
public func captureText(_ raw: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? // nil if empty after normalization
@discardableResult
- public func captureImage(data: Data, format: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)?
+ public func captureImage(data: Data, format: String, sourceApp: String?) async throws -> (entry: ClipboardEntry, wasDuplicate: Bool)?
// Queries
public func list(type: EntryType?, limit: Int, offset: Int) throws -> [ClipboardEntry]
@@ -182,6 +201,36 @@ public enum SafeRegex {
/// Never throws at match time; invalid pattern → .invalidPattern error on compile.
public static func compile(_ pattern: String) throws -> NSRegularExpression
}
+public enum TextSummaries {
+ public static func singleLine(_ s: String, maxChars: Int) -> String // collapse ws/control chars, "…" truncate
+ public static func relativeTime(_ date: Date, now: Date) -> String // "now", "5m", "2h", "3d", else "yyyy-MM-dd"
+}
+public enum ImageFormats {
+ public static func uti(forFormat format: String) -> String? // 'gif' → 'com.compuserve.gif'
+}
+public enum ConfigKey { /* typed constants for every config-table key */ }
+
+/// Injectable OCR seam (Vision-backed default; tests use stubs).
+public protocol OCREngine: Sendable {
+ func recognizeText(from imageData: Data) async -> String?
+}
+public struct VisionOCREngine: OCREngine {}
+
+// Shared clipboard content analysis (used by app UI and available to CLI):
+public struct ParsedColor: Sendable, Equatable {} // r/g/b/a components
+public enum ColorParser { public static func parse(_ raw: String?) -> ParsedColor? }
+public enum CaseConverter { /* camel/pascal/snake/kebab/constant/upper/lower/title */ }
+public enum TextTransformer { /* Base64 + URL encode/decode with length guards */ }
+public struct JWTData: Sendable, Equatable { public static func parse(_ text: String?) -> JWTData? }
+public struct EpochData: Sendable, Equatable { public static func parse(_ text: String?) -> EpochData? }
+
+// IPC names shared by both processes:
+public enum ClapIdentity { public static let bundleID = "com.spongycode.clap" }
+public enum IPCNotifications {
+ public static let openUI = "com.spongycode.clap.openUI"
+ public static let storeChanged = "com.spongycode.clap.storeChanged"
+ public static let configChanged = "com.spongycode.clap.configChanged"
+}
```
Notes:
@@ -244,7 +293,7 @@ pgrep ClapApp), print hint to start the app.
- UI lists are paged: fetch 100 rows, fetch more as selection/scroll nears the
end. Media tab = LazyVGrid of thumbnails.
- Keys: ↑/↓ navigate, Enter copy+close, Esc close, Cmd+F focus search,
- Cmd+1/Cmd+2 tabs, Cmd+P pin toggle, Cmd+D or Option+Delete delete (the
+ Cmd+1-Cmd+4 tabs, Cmd+P pin toggle, Cmd+D or Option+Delete delete (the
latter yields to delete-word while editing a non-empty search), hover
selects a row (pointer-driven selection never auto-scrolls), Cmd+R
regex-mode toggle
@@ -291,10 +340,20 @@ clap pause / clap resume
- All commands honor `--data-dir ` and `CLAP_DATA_DIR`.
- Exit codes: 0 ok, 1 not found / no match, 2 usage error.
-## Testing
-
-Tests use a temp `CLAP_DATA_DIR`. Cover: normalization, hashing stability,
-dedup (capture same text twice → 1 row, recency bumped), recency ordering,
-count eviction, byte-size eviction, pinned immunity, clear, search (terms,
-phrase, type filter), regex search incl. invalid pattern error, ByteSize
-parse/format, SearchQuery.parse, retention.
+## Testing & quality gates
+
+Tests use a temp `CLAP_DATA_DIR` (via `withStore`) plus injected `now:` clock
+and stub `OCREngine`. Cover: normalization, hashing stability, dedup (capture
+same text twice → 1 row, recency bumped), recency ordering, count eviction,
+byte-size eviction, pinned immunity, clear, search (terms, phrase, type
+filter), regex search incl. invalid pattern error, ByteSize parse/format,
+SearchQuery.parse, retention, OCR seam (mocked engine stores searchable text),
+injected-clock determinism, text analysis (color/case/transform/JWT/epoch),
+TextSummaries, ImageFormats, CLI ArgParser/OutputFormatter, and the app's
+AppState logic (hover-selection gate, tab→query mapping).
+
+CI (`.github/workflows/ci.yml`) enforces three gates on every push:
+`swift build -Xswiftc -warnings-as-errors`, full `swift test`, and a zero-
+violation `swiftlint` pass (config in `.swiftlint.yml`). The UI is English-
+only by design; SwiftUI text literals are already localization-ready should
+translations ever be added.
diff --git a/Package.swift b/Package.swift
index 7b765ea..5c8f66d 100644
--- a/Package.swift
+++ b/Package.swift
@@ -7,8 +7,8 @@ let package = Package(
.macOS(.v14)
],
targets: [
- // Core engine: database, search, dedup, eviction, settings, image store.
- // No UI. Shared by the app and the CLI.
+ // Core engine: database, search, dedup, eviction, settings, image store,
+ // text analysis. No UI. Shared by the app and the CLI.
.target(
name: "ClapCore",
swiftSettings: [.swiftLanguageMode(.v5)],
@@ -20,10 +20,17 @@ let package = Package(
dependencies: ["ClapCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
+ // CLI command logic as a library so it is unit-testable; the executable
+ // target below stays a thin entry point.
+ .target(
+ name: "ClapCLIKit",
+ dependencies: ["ClapCore"],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
// Command-line interface. The binary is named `clap`.
.executableTarget(
name: "clap",
- dependencies: ["ClapCore"],
+ dependencies: ["ClapCLIKit"],
path: "Sources/ClapCLI",
swiftSettings: [.swiftLanguageMode(.v5)]
),
@@ -32,5 +39,17 @@ let package = Package(
dependencies: ["ClapCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
+ .testTarget(
+ name: "ClapCLITests",
+ dependencies: ["ClapCLIKit"],
+ path: "Tests/ClapCLITests",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .testTarget(
+ name: "ClapAppTests",
+ dependencies: ["ClapApp"],
+ path: "Tests/ClapAppTests",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ )
]
)
diff --git a/README.md b/README.md
index 2ff2d0f..5682041 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
- **Keyboard-first & Fast** — `⌘⇧V` opens the panel; single click or `Enter` copies, closes, and pastes directly.
- **Screenshot OCR & Search** — Apple Vision extracts text from screenshots in the background; search inside images (`⌘F`) or copy text with one click.
- **Global Snippet Expansion** — Assign trigger abbreviations (e.g. `;email`, `!zoom`, `brb`) to saved snippets to auto-expand them anywhere as you type.
-- **Permanent Favorites & Snippets** — Bookmark canned replies, email signatures, code snippets, and commands (`⌘S` / `⌘4`).
+- **Permanent Favorites & Snippets** — Bookmark canned replies, email signatures, code snippets, and commands (`⌘S` / `⌘3`).
- **Developer Smart Cards** — Automatic previews and one-click actions for Base64, URL encoding, JWT tokens, and Unix timestamps.
- **Text Case & Encoding Transforms** — Convert text on the fly (`camelCase`, `snake_case`, Base64, URL encode/decode).
- **Smart Color Swatch Detection** — Recognizes `#hex`, `rgb()`, `rgba()`, `hsl()` color codes with live inline circle swatches.
@@ -108,7 +108,7 @@ Press **⌘⇧V** to open the panel (configurable in Settings to `⌘⇧B`, `⌘
| **Single Click** | Copy entry, close panel, and paste directly into active app |
| **↑ / ↓** | Navigate entries |
| **Enter** | Copy selected entry, close, and paste into active app |
-| **⌘1 / ⌘2 / ⌘3 / ⌘4** | Switch tabs: **Classic (⌘1)** · **Media (⌘2)** · **Shell (⌘3)** · **Favs (⌘4)** |
+| **⌘1 / ⌘2 / ⌘3 / ⌘4** | Switch tabs: **Classic (⌘1)** · **Shell (⌘2)** · **Favs (⌘3)** · **Media (⌘4)** |
| **⌘S** or **⌘B** | Toggle Favorite / Bookmark on selected entry (marked with ❤️) |
| **⌘P** | Pin / unpin selected entry (pinned items stick to top of Classic view) |
| **⌘F** | Focus search bar |
diff --git a/Scripts/Info.plist b/Scripts/Info.plist
index fda9034..22f2660 100644
--- a/Scripts/Info.plist
+++ b/Scripts/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.1
+ 0.2.0
CFBundleVersion
- 4
+ 5
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/Sources/ClapApp/AppConstants.swift b/Sources/ClapApp/AppConstants.swift
new file mode 100644
index 0000000..9c7e2c7
--- /dev/null
+++ b/Sources/ClapApp/AppConstants.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+/// Design tokens for ClapApp: timings and recurring style alphas in one
+/// place so behavior tuning never requires grepping for nanoseconds.
+enum Timing {
+ /// Search-as-you-type debounce.
+ static let searchDebounceNanos: UInt64 = 150_000_000
+ /// Pasteboard polling cadence.
+ static let pasteboardPollNanos: UInt64 = 150_000_000
+ static let shellHistoryPollNanos: UInt64 = 2_000_000_000
+ /// Panel frame persistence debounce (windowDidMove fires continuously).
+ static let frameSaveDebounceNanos: UInt64 = 300_000_000
+ /// Delay between closing the panel and synthesizing Cmd+V.
+ static let pasteDelayNanos: UInt64 = 100_000_000
+ /// "Copied" button label reset.
+ static let copiedResetNanos: UInt64 = 1_500_000_000
+ /// Transient error banner auto-dismiss.
+ static let errorBannerResetNanos: UInt64 = 4_000_000_000
+ /// Settings save-failure banner auto-dismiss.
+ static let saveErrorResetNanos: UInt64 = 5_000_000_000
+}
+
+/// Recurring translucency values. One-off contextual alphas stay inline.
+enum AppAlpha {
+ enum Fill {
+ static let subtle: Double = 0.04
+ static let soft: Double = 0.06
+ }
+ enum Stroke {
+ static let hairline: Double = 0.08
+ static let panelBorder: Double = 0.12
+ }
+ enum Hover {
+ static let fill: Double = 0.09
+ static let strongFill: Double = 0.12
+ }
+}
diff --git a/Sources/ClapApp/AppDelegate.swift b/Sources/ClapApp/AppDelegate.swift
index dd8f2c2..e32602d 100644
--- a/Sources/ClapApp/AppDelegate.swift
+++ b/Sources/ClapApp/AppDelegate.swift
@@ -5,7 +5,7 @@ import os
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
- private let logger = Logger(subsystem: "com.spongycode.clap", category: "app")
+ private let logger = Logger(subsystem: ClapIdentity.bundleID, category: "app")
private var store: ClipboardStore!
private var monitor: PasteboardMonitor!
@@ -39,6 +39,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
appState = AppState(store: store, monitor: monitor)
panelController = PanelController(appState: appState)
settingsController = SettingsWindowController(store: store)
+ settingsController.healthProvider = { [weak self] in
+ (self?.hotKey?.isRegistered ?? false, SnippetExpander.shared.isHealthy)
+ }
menuBar = MenuBarController(store: store, appState: appState)
appState.onCloseRequest = { [weak self] in self?.panelController.hide(reactivatePreviousApp: true) }
@@ -53,16 +56,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
installDistributedObservers()
Task { [weak self, monitor, shellMonitor, store] in
- let savedKey = (try? await store.config("ui.hotkey")) ?? "cmd+shift+v"
+ let savedKey = (try? await store.config(ConfigKey.uiHotkey)) ?? HotKeyDefinition.defaultID
let def = HotKeyDefinition.find(savedKey)
self?.hotKey.register(definition: def)
self?.menuBar.updateShortcut(def)
+ if let hotKey = self?.hotKey, !hotKey.isRegistered {
+ self?.logger.fault("global hotkey registration failed: \(hotKey.statusDescription, privacy: .public)")
+ }
await monitor?.refreshConfig()
await monitor?.start()
await shellMonitor?.start()
- let snippetsEnabled = (try? await store.config("snippets.enabled")) != "false"
+ // Settings writes "1"/"0"; anything but "0" means enabled.
+ let snippetsEnabled = (try? await store.config(ConfigKey.snippetsEnabled)) != "0"
SnippetExpander.shared.setEnabled(snippetsEnabled)
let shortcuts = (try? await store.allShortcuts()) ?? [:]
SnippetExpander.shared.updateSnippets(shortcuts)
@@ -75,6 +82,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
SnippetExpander.shared.stop()
+ workers.stop()
Task { [monitor, shellMonitor] in
await monitor?.stop()
await shellMonitor?.stop()
@@ -101,7 +109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task {
await self.monitor.refreshConfig()
await self.shellMonitor.refreshConfig()
- let savedKey = (try? await self.store.config("ui.hotkey")) ?? "cmd+shift+v"
+ let savedKey = (try? await self.store.config(ConfigKey.uiHotkey)) ?? HotKeyDefinition.defaultID
let def = HotKeyDefinition.find(savedKey)
self.hotKey.register(definition: def)
self.menuBar.updateShortcut(def)
@@ -120,7 +128,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard let self else { return }
self.panelController.show()
if ProcessInfo.processInfo.environment["CLAP_DEBUG_SNAPSHOT_TAB"] == "media" {
- self.appState.tab = .media
+ self.appState.selectTab(.media)
}
// Select the first row so the preview window appears too.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
@@ -129,7 +137,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
let dir = URL(fileURLWithPath: snapshotDir, isDirectory: true)
self.panelController.writeSnapshot(to: dir.appendingPathComponent("panel.png"))
- self.panelController.writePreviewSnapshot(to: dir.appendingPathComponent("preview.png"))
}
}
}
diff --git a/Sources/ClapApp/AppIconView.swift b/Sources/ClapApp/AppIconView.swift
new file mode 100644
index 0000000..64fc1a4
--- /dev/null
+++ b/Sources/ClapApp/AppIconView.swift
@@ -0,0 +1,46 @@
+import AppKit
+import SwiftUI
+
+/// Caches and resolves native macOS application icons from bundle identifiers.
+public enum AppIconCache {
+ private static let cache = NSCache()
+
+ public static func icon(forBundleID bundleID: String, size: CGFloat = 16) -> NSImage? {
+ let key = "\(bundleID)-\(Int(size))" as NSString
+ if let cached = cache.object(forKey: key) {
+ return cached
+ }
+ guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else {
+ return nil
+ }
+ let icon = NSWorkspace.shared.icon(forFile: url.path)
+ icon.size = NSSize(width: size, height: size)
+ cache.setObject(icon, forKey: key)
+ return icon
+ }
+}
+
+/// SwiftUI view that renders the application icon for a bundle identifier.
+public struct AppIconView: View {
+ let bundleID: String
+ var size: CGFloat
+
+ public init(bundleID: String, size: CGFloat = 14) {
+ self.bundleID = bundleID
+ self.size = size
+ }
+
+ public var body: some View {
+ if let icon = AppIconCache.icon(forBundleID: bundleID, size: size) {
+ Image(nsImage: icon)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: size, height: size)
+ } else {
+ Image(systemName: "app.dashed")
+ .resizable()
+ .frame(width: size, height: size)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/Sources/ClapApp/AppState+Pasteboard.swift b/Sources/ClapApp/AppState+Pasteboard.swift
new file mode 100644
index 0000000..1ee059d
--- /dev/null
+++ b/Sources/ClapApp/AppState+Pasteboard.swift
@@ -0,0 +1,120 @@
+import AppKit
+import ClapCore
+import os
+
+// MARK: - Pasteboard copy & thumbnail loading
+
+extension AppState {
+
+ // MARK: - Copy to pasteboard
+
+ /// Writes the entry to NSPasteboard.general. The monitor is told about
+ /// the expected self-inflicted change first so it only bumps recency
+ /// instead of re-capturing.
+ func copy(_ entry: ClipboardEntry) {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ let wrote = await self.writeToPasteboard(entry)
+ if entry.type != .image || wrote {
+ IPC.post(.storeChanged)
+ self.onCloseRequest?()
+ await self.pasteToFrontmostIfEnabled()
+ }
+ }
+ }
+
+ /// Returns false when an image copy failed (panel stays open so the user
+ /// sees that nothing happened).
+ private func writeToPasteboard(_ entry: ClipboardEntry) async -> Bool {
+ let pasteboard = NSPasteboard.general
+ switch entry.type {
+ case .text:
+ await monitor.expectSelfChange(entryID: entry.id)
+ pasteboard.clearContents()
+ pasteboard.setString(entry.content ?? "", forType: .string)
+ await monitor.confirmSelfChange(changeCount: pasteboard.changeCount)
+ case .shell:
+ pasteboard.clearContents()
+ pasteboard.setString(entry.content ?? "", forType: .string)
+ await perform("Recency touch") { try await store.touch(id: entry.id) }
+ case .image:
+ // Load the full image data first: only tell the monitor once
+ // we know the write will actually happen.
+ guard let url = await store.imageFileURL(for: entry) else { return false }
+ let data = await Task.detached(priority: .userInitiated) {
+ try? Data(contentsOf: url)
+ }.value
+ guard let data else {
+ logger.error("copy failed: image file missing for entry \(entry.id, privacy: .public)")
+ showTransientError("Image file missing")
+ return false
+ }
+ await monitor.expectSelfChange(entryID: entry.id)
+ pasteboard.clearContents()
+ if let uti = ImageFormats.uti(forFormat: entry.imageFormat ?? "") {
+ pasteboard.setData(data, forType: NSPasteboard.PasteboardType(uti))
+ } else {
+ // Unknown format: convert through NSImage to TIFF.
+ if let tiff = NSImage(data: data)?.tiffRepresentation {
+ pasteboard.setData(tiff, forType: .tiff)
+ } else {
+ pasteboard.setData(data, forType: .tiff)
+ }
+ }
+ await monitor.confirmSelfChange(changeCount: pasteboard.changeCount)
+ }
+ return true
+ }
+
+ /// Paste-on-select: the panel never activated clap, so the
+ /// app the user came from still has key focus. Small delay so the panel
+ /// is gone and the pasteboard write has settled before the synthetic
+ /// Cmd+V lands.
+ private func pasteToFrontmostIfEnabled() async {
+ let pasteEnabled = ((try? await store.config(ConfigKey.pasteOnCopy)) ?? "1") == "1"
+ guard pasteEnabled else { return }
+ try? await Task.sleep(nanoseconds: Timing.pasteDelayNanos)
+ Paster.pasteToFrontmostApp()
+ }
+
+ /// Writes transformed text to clipboard, captures it as a new entry,
+ /// closes the panel, and optionally pastes it to the frontmost app.
+ func copyTransformedText(_ text: String) {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(text, forType: .string)
+ await self.perform("Capture transformed text") {
+ try await self.store.captureText(text, sourceApp: "clap")
+ }
+ IPC.post(.storeChanged)
+ self.reload()
+ self.onCloseRequest?()
+ await self.pasteToFrontmostIfEnabled()
+ }
+ }
+
+ // MARK: - Thumbnails
+
+ /// Loads (and lazily generates) the thumbnail for an image entry,
+ /// cached in a small NSCache.
+ func thumbnail(for entry: ClipboardEntry) async -> NSImage? {
+ let key = NSNumber(value: entry.id)
+ if let cached = thumbnailCache.object(forKey: key) { return cached }
+ guard let url = try? await store.thumbnailURL(for: entry) else { return nil }
+ let image = await Task.detached(priority: .utility) {
+ NSImage(contentsOf: url)
+ }.value
+ if let image { thumbnailCache.setObject(image, forKey: key) }
+ return image
+ }
+
+ /// Loads the full-resolution image for preview rendering (not cached).
+ func fullImage(for entry: ClipboardEntry) async -> NSImage? {
+ guard let url = await store.imageFileURL(for: entry) else { return nil }
+ return await Task.detached(priority: .userInitiated) {
+ NSImage(contentsOf: url)
+ }.value
+ }
+}
diff --git a/Sources/ClapApp/ContentView.swift b/Sources/ClapApp/ContentView.swift
index eca9c75..0cf2304 100644
--- a/Sources/ClapApp/ContentView.swift
+++ b/Sources/ClapApp/ContentView.swift
@@ -5,26 +5,38 @@ import ClapCore
struct ContentView: View {
@EnvironmentObject private var state: AppState
@FocusState private var searchFocused: Bool
+ @State private var lastEntry: ClipboardEntry?
var body: some View {
- VStack(spacing: 0) {
- header
- Divider()
- content
+ SlideoutView(controller: state.slideout) {
+ VStack(spacing: 0) {
+ header
+ Divider()
+ content
+ }
+ } slideout: {
+ if let entry = state.selectedEntry ?? lastEntry {
+ PreviewView(entry: entry)
+ } else {
+ EmptyView()
+ }
}
- // Flexible: tracks the window as the user resizes the panel.
- .frame(minWidth: PanelController.minPanelSize.width,
- maxWidth: .infinity,
- minHeight: PanelController.minPanelSize.height,
+ .frame(minHeight: PanelController.minPanelSize.height,
maxHeight: .infinity)
- .background(VisualEffectBackground())
+ .background(AdaptivePanelBackground())
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)
+ .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder), lineWidth: 1)
)
+ .overlay(EdgeResizeOverlay())
.onAppear { searchFocused = true }
.onChange(of: state.searchFocusToken) { _, _ in searchFocused = true }
+ .onChange(of: state.selectedEntry) { _, newEntry in
+ if let newEntry {
+ lastEntry = newEntry
+ }
+ }
}
// MARK: - Header (search + tabs + gear)
@@ -39,10 +51,13 @@ struct ContentView: View {
.foregroundStyle(.secondary)
TextField(state.regexMode ? "Regex search…" : "Search…",
- text: $state.rawQuery)
+ text: Binding(
+ get: { state.rawQuery },
+ set: { state.queryChanged($0) }))
.textFieldStyle(.plain)
.font(.system(size: 14))
.focused($searchFocused)
+ .accessibilityIdentifier("search-field")
regexToggle
}
@@ -53,7 +68,7 @@ struct ContentView: View {
.fill(Color.primary.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
+ .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.hairline), lineWidth: 0.5)
)
)
@@ -65,6 +80,9 @@ struct ContentView: View {
state.onOpenSettings?()
}
}
+ if state.tab == .favs {
+ tagFilterBar
+ }
if let error = state.searchError {
Text(error)
.font(.caption)
@@ -74,6 +92,97 @@ struct ContentView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
+ .overlay(alignment: .bottom) {
+ if let message = state.transientError {
+ TransientErrorBanner(message: message)
+ }
+ }
+ }
+
+ private struct TransientErrorBanner: View {
+ @EnvironmentObject private var state: AppState
+ let message: String
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ Text(message)
+ Button {
+ state.dismissTransientError()
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Dismiss error")
+ }
+ .font(.caption)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(Capsule().fill(Color.red.opacity(0.9)))
+ .padding(.bottom, 8)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ .accessibilityIdentifier("error-banner")
+ }
+ }
+
+ /// Horizontal scrolling tag filter pills bar in the Favs / Pinboards tab
+ private var tagFilterBar: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ // "All" Pill
+ tagPill(
+ title: "All",
+ count: state.selectedTag == nil ? (state.entries.count + state.pinned.count) : nil,
+ isSelected: state.selectedTag == nil
+ ) {
+ state.selectTag(nil)
+ }
+
+ // Tag Pills
+ ForEach(state.availableTags, id: \.tag) { item in
+ tagPill(
+ title: "#\(item.tag)",
+ count: item.count,
+ isSelected: state.selectedTag?.lowercased() == item.tag.lowercased()
+ ) {
+ state.selectTag(item.tag)
+ }
+ }
+ }
+ .padding(.horizontal, 2)
+ .padding(.top, 4)
+ .padding(.bottom, 2)
+ }
+ }
+
+ private func tagPill(title: String, count: Int?, isSelected: Bool, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack(spacing: 4) {
+ Text(title)
+ .font(.system(size: 11.5, weight: isSelected ? .bold : .medium,
+ design: title.hasPrefix("#") ? .monospaced : .default))
+ if let count {
+ Text("\(count)")
+ .font(.system(size: 10, weight: isSelected ? .bold : .regular))
+ .foregroundStyle(isSelected ? Color.white.opacity(0.85) : Color.secondary)
+ .padding(.horizontal, 4)
+ .padding(.vertical, 1)
+ .background(
+ Capsule()
+ .fill(isSelected ? Color.white.opacity(0.2) : Color.primary.opacity(AppAlpha.Fill.soft))
+ )
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3.5)
+ .foregroundStyle(isSelected ? Color.white : Color.primary)
+ .background(
+ Capsule()
+ .fill(isSelected ? Color.accentColor : Color.primary.opacity(AppAlpha.Fill.soft))
+ )
+ }
+ .buttonStyle(.plain)
}
/// Settings gear button with circular hover highlight
@@ -89,13 +198,15 @@ struct ContentView: View {
.frame(width: 28, height: 28)
.background(
Circle()
- .fill(isHovered ? Color.primary.opacity(0.09) : Color.clear)
+ .fill(isHovered ? Color.primary.opacity(AppAlpha.Hover.fill) : Color.clear)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.help("Settings")
+ .accessibilityLabel("Settings")
+ .accessibilityIdentifier("settings-button")
}
}
@@ -103,22 +214,22 @@ struct ContentView: View {
private var customTabBar: some View {
HStack(spacing: 2) {
TabButton(icon: "doc.on.clipboard", tab: .classic, currentTab: state.tab, shortcut: "⌘1", label: "Classic") {
- state.tab = .classic
+ state.selectTab(.classic)
}
- TabButton(icon: "photo", tab: .media, currentTab: state.tab, shortcut: "⌘2", label: "Media") {
- state.tab = .media
+ TabButton(icon: "terminal", tab: .shell, currentTab: state.tab, shortcut: "⌘2", label: "Shell") {
+ state.selectTab(.shell)
}
- TabButton(icon: "terminal", tab: .shell, currentTab: state.tab, shortcut: "⌘3", label: "Shell") {
- state.tab = .shell
+ TabButton(icon: "heart.fill", tab: .favs, currentTab: state.tab, shortcut: "⌘3", label: "Favs") {
+ state.selectTab(.favs)
}
- TabButton(icon: "heart.fill", tab: .favs, currentTab: state.tab, shortcut: "⌘4", label: "Favs") {
- state.tab = .favs
+ TabButton(icon: "photo", tab: .media, currentTab: state.tab, shortcut: "⌘4", label: "Media") {
+ state.selectTab(.media)
}
}
.padding(2.5)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.primary.opacity(0.06))
+ .fill(Color.primary.opacity(AppAlpha.Fill.soft))
)
}
@@ -148,7 +259,8 @@ struct ContentView: View {
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(
- isSelected ? Color.accentColor : (isHovered ? Color.primary.opacity(0.09) : Color.clear)
+ isSelected ? Color.accentColor
+ : (isHovered ? Color.primary.opacity(AppAlpha.Hover.fill) : Color.clear)
)
.shadow(color: isSelected ? Color.accentColor.opacity(0.3) : Color.clear, radius: 2, y: 1)
)
@@ -157,12 +269,19 @@ struct ContentView: View {
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.help("\(label) (\(shortcut))")
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(label) tab")
+ .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
+ .accessibilityIdentifier("tab-\(tab.rawValue)")
}
}
/// Regex on/off toggle with hover highlight and active state
private var regexToggle: some View {
- RegexToggle(isOn: $state.regexMode)
+ RegexToggle(isOn: Binding(
+ get: { state.regexMode },
+ set: { state.setRegexMode($0) }
+ ))
}
private struct RegexToggle: View {
@@ -185,7 +304,9 @@ struct ContentView: View {
.fill(
isOn
? Color.green.opacity(isHovered ? 0.24 : 0.16)
- : (isHovered ? Color.primary.opacity(0.12) : Color.primary.opacity(0.06))
+ : (isHovered
+ ? Color.primary.opacity(AppAlpha.Hover.strongFill)
+ : Color.primary.opacity(AppAlpha.Fill.soft))
)
)
.overlay(
@@ -203,6 +324,10 @@ struct ContentView: View {
.onHover { isHovered = $0 }
.help(isOn ? "Regex search on (⌘R to turn off)"
: "Regex search off (⌘R to turn on)")
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Regex search mode")
+ .accessibilityAddTraits(isOn ? [.isSelected, .isButton] : .isButton)
+ .accessibilityIdentifier("regex-toggle")
}
}
@@ -306,23 +431,25 @@ struct SectionHeaderView: View {
struct MediaGridView: View {
@EnvironmentObject private var state: AppState
- private let columns = Array(
- repeating: GridItem(.flexible(), spacing: 10),
- count: 4
- )
-
var body: some View {
- ScrollViewReader { proxy in
- ScrollView {
- LazyVGrid(columns: columns, spacing: 10) {
- ForEach(state.entries) { entry in
- MediaCell(entry: entry)
+ GeometryReader { geometry in
+ let columnCount = max(2, Int(geometry.size.width / 160))
+ let columns = Array(
+ repeating: GridItem(.flexible(), spacing: 10),
+ count: columnCount
+ )
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVGrid(columns: columns, spacing: 10) {
+ ForEach(state.entries) { entry in
+ MediaCell(entry: entry)
+ }
}
+ .padding(12)
+ }
+ .onChange(of: state.selectedID) { _, id in
+ if let id, !state.selectionCameFromPointer { proxy.scrollTo(id) }
}
- .padding(12)
- }
- .onChange(of: state.selectedID) { _, id in
- if let id, !state.selectionCameFromPointer { proxy.scrollTo(id) }
}
}
}
diff --git a/Sources/ClapApp/EdgeResizeOverlay.swift b/Sources/ClapApp/EdgeResizeOverlay.swift
new file mode 100644
index 0000000..78b90fd
--- /dev/null
+++ b/Sources/ClapApp/EdgeResizeOverlay.swift
@@ -0,0 +1,255 @@
+import SwiftUI
+import AppKit
+
+// MARK: - Manual window edge/corner resize handles
+//
+// Native titled-window resize zones proved unreliable on this nonactivating
+// panel, so we provide explicit 9pt strips + 20pt corner pads. Each handle
+// drives the window frame directly during drag; min size and column widths are enforced.
+
+enum ResizeDirection {
+ case left, right, top, bottom
+ case topLeft, topRight, bottomLeft, bottomRight
+
+ var cursor: NSCursor {
+ switch self {
+ case .left, .right: return .resizeLeftRight
+ case .top, .bottom: return .resizeUpDown
+ case .topLeft, .bottomRight: return .closedHand
+ case .topRight, .bottomLeft: return .closedHand
+ }
+ }
+}
+
+private final class ResizerView: NSView {
+ let direction: ResizeDirection
+ var slideoutController: SlideoutController?
+ private var initialMouseLocation: NSPoint?
+ private var initialWindowFrame: NSRect?
+ private var initialContentWidth: CGFloat = 480
+ private var initialSlideoutWidth: CGFloat = 360
+ private var trackingArea: NSTrackingArea?
+
+ init(direction: ResizeDirection, slideoutController: SlideoutController?) {
+ self.direction = direction
+ self.slideoutController = slideoutController
+ super.init(frame: .zero)
+ }
+
+ required init?(coder: NSCoder) { fatalError("unsupported") }
+
+ override var mouseDownCanMoveWindow: Bool { false }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingArea {
+ removeTrackingArea(trackingArea)
+ }
+ let area = NSTrackingArea(
+ rect: bounds,
+ options: [.mouseEnteredAndExited, .activeAlways, .cursorUpdate],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ self.trackingArea = area
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ window?.isMovableByWindowBackground = false
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ if initialMouseLocation == nil {
+ window?.isMovableByWindowBackground = true
+ }
+ }
+
+ override func cursorUpdate(with event: NSEvent) {
+ direction.cursor.set()
+ }
+
+ override func resetCursorRects() {
+ addCursorRect(bounds, cursor: direction.cursor)
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ guard let window else { return }
+ window.isMovableByWindowBackground = false
+ initialMouseLocation = NSEvent.mouseLocation
+ initialWindowFrame = window.frame
+ if let slideout = slideoutController {
+ initialContentWidth = slideout.contentWidth
+ initialSlideoutWidth = slideout.slideoutWidth
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ window?.isMovableByWindowBackground = true
+ initialMouseLocation = nil
+ initialWindowFrame = nil
+ }
+
+ override func mouseDragged(with event: NSEvent) {
+ guard let window,
+ let startMouse = initialMouseLocation,
+ let startFrame = initialWindowFrame else { return }
+
+ let currentMouse = NSEvent.mouseLocation
+ let totalDx = currentMouse.x - startMouse.x
+ let totalDy = currentMouse.y - startMouse.y
+
+ let screenFrame = window.screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 3840, height: 2160)
+ let maxW = max(PanelController.minPanelSize.width, screenFrame.width - 20)
+ let maxH = max(PanelController.minPanelSize.height, screenFrame.height - 20)
+
+ var newOrigin = startFrame.origin
+ var newSize = startFrame.size
+
+ let isOpen = slideoutController?.state.isOpen ?? false
+ let isLeftPlacement = (slideoutController?.placement == .left)
+
+ // 1. Horizontal Calculation
+ switch direction {
+ case .left, .topLeft, .bottomLeft:
+ if let slideout = slideoutController {
+ if isOpen {
+ if isLeftPlacement {
+ // Left edge is Preview
+ let newPrevW = max(slideout.minimumSlideoutWidth, initialSlideoutWidth - totalDx)
+ let clampedPrevW = min(maxW - initialContentWidth, newPrevW)
+ let totalW = clampedPrevW + initialContentWidth
+ slideout.slideoutWidth = clampedPrevW
+ newSize.width = totalW
+ newOrigin.x = startFrame.maxX - totalW
+ } else {
+ // Left edge is List
+ let newContW = max(slideout.minimumContentWidth, initialContentWidth - totalDx)
+ let clampedContW = min(maxW - initialSlideoutWidth, newContW)
+ let totalW = clampedContW + initialSlideoutWidth
+ slideout.contentWidth = clampedContW
+ newSize.width = totalW
+ newOrigin.x = startFrame.maxX - totalW
+ }
+ } else {
+ let newContW = max(slideout.minimumContentWidth, initialContentWidth - totalDx)
+ let clampedContW = min(maxW, newContW)
+ slideout.contentWidth = clampedContW
+ newSize.width = clampedContW
+ newOrigin.x = startFrame.maxX - clampedContW
+ }
+ }
+
+ case .right, .topRight, .bottomRight:
+ if let slideout = slideoutController {
+ if isOpen {
+ if isLeftPlacement {
+ // Right edge is List
+ let newContW = max(slideout.minimumContentWidth, initialContentWidth + totalDx)
+ let clampedContW = min(maxW - initialSlideoutWidth, newContW)
+ let totalW = clampedContW + initialSlideoutWidth
+ slideout.contentWidth = clampedContW
+ newSize.width = totalW
+ } else {
+ // Right edge is Preview
+ let newPrevW = max(slideout.minimumSlideoutWidth, initialSlideoutWidth + totalDx)
+ let clampedPrevW = min(maxW - initialContentWidth, newPrevW)
+ let totalW = initialContentWidth + clampedPrevW
+ slideout.slideoutWidth = clampedPrevW
+ newSize.width = totalW
+ }
+ } else {
+ let newContW = max(slideout.minimumContentWidth, initialContentWidth + totalDx)
+ let clampedContW = min(maxW, newContW)
+ slideout.contentWidth = clampedContW
+ newSize.width = clampedContW
+ }
+ }
+
+ default:
+ break
+ }
+
+ // 2. Vertical Calculation (Cocoa screen coordinates: +Y is UP)
+ switch direction {
+ case .top, .topLeft, .topRight:
+ let desiredHeight = startFrame.height + totalDy
+ newSize.height = min(maxH, max(PanelController.minPanelSize.height, desiredHeight))
+ case .bottom, .bottomLeft, .bottomRight:
+ let desiredHeight = startFrame.height - totalDy
+ let clampedHeight = min(maxH, max(PanelController.minPanelSize.height, desiredHeight))
+ newSize.height = clampedHeight
+ newOrigin.y = startFrame.maxY - clampedHeight
+ default:
+ break
+ }
+
+ window.setFrame(NSRect(origin: newOrigin, size: newSize), display: true)
+ }
+}
+
+private struct ResizerHandle: NSViewRepresentable {
+ let direction: ResizeDirection
+ let slideoutController: SlideoutController?
+
+ func makeNSView(context: Context) -> NSView {
+ let view = ResizerView(direction: direction, slideoutController: slideoutController)
+ view.autoresizingMask = [.width, .height]
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ if let resizer = nsView as? ResizerView {
+ resizer.slideoutController = slideoutController
+ }
+ }
+}
+
+/// Perimeter resize affordances: 9pt edge strips + 20pt corner pads.
+struct EdgeResizeOverlay: View {
+ @EnvironmentObject private var state: AppState
+
+ private let edge: CGFloat = 9
+ private let corner: CGFloat = 20
+
+ var body: some View {
+ GeometryReader { geo in
+ let w = geo.size.width
+ let h = geo.size.height
+ Group {
+ // Edges
+ ResizerHandle(direction: .left, slideoutController: state.slideout)
+ .frame(width: edge, height: h - corner * 2)
+ .position(x: edge / 2, y: h / 2)
+ ResizerHandle(direction: .right, slideoutController: state.slideout)
+ .frame(width: edge, height: h - corner * 2)
+ .position(x: w - edge / 2, y: h / 2)
+ ResizerHandle(direction: .top, slideoutController: state.slideout)
+ .frame(width: w - corner * 2, height: edge)
+ .position(x: w / 2, y: edge / 2)
+ ResizerHandle(direction: .bottom, slideoutController: state.slideout)
+ .frame(width: w - corner * 2, height: edge)
+ .position(x: w / 2, y: h - edge / 2)
+
+ // Corners
+ ResizerHandle(direction: .topLeft, slideoutController: state.slideout)
+ .frame(width: corner, height: corner)
+ .position(x: corner / 2, y: corner / 2)
+ ResizerHandle(direction: .topRight, slideoutController: state.slideout)
+ .frame(width: corner, height: corner)
+ .position(x: w - corner / 2, y: corner / 2)
+ ResizerHandle(direction: .bottomLeft, slideoutController: state.slideout)
+ .frame(width: corner, height: corner)
+ .position(x: corner / 2, y: h - corner / 2)
+ ResizerHandle(direction: .bottomRight, slideoutController: state.slideout)
+ .frame(width: corner, height: corner)
+ .position(x: w - corner / 2, y: h - corner / 2)
+ }
+ }
+ .allowsHitTesting(true)
+ }
+}
diff --git a/Sources/ClapApp/GlassEffectBackground.swift b/Sources/ClapApp/GlassEffectBackground.swift
new file mode 100644
index 0000000..a4199a6
--- /dev/null
+++ b/Sources/ClapApp/GlassEffectBackground.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+import AppKit
+
+// MARK: - Panel background materials
+//
+// macOS 26 introduced Liquid Glass (NSGlassEffectView). On older systems we
+// fall back to the classic popover vibrancy. Both fill the panel behind the
+// SwiftUI content; the switch happens at runtime via AdaptivePanelBackground.
+
+/// Classic pre-26 vibrancy.
+struct VisualEffectBackground: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSVisualEffectView {
+ let view = NSVisualEffectView()
+ view.material = .popover
+ view.blendingMode = .behindWindow
+ view.state = .active
+ return view
+ }
+
+ func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
+}
+
+/// Liquid Glass material (macOS 26+).
+@available(macOS 26.0, *)
+struct LiquidGlassBackground: NSViewRepresentable {
+ var style: NSGlassEffectView.Style = .regular
+
+ func makeNSView(context: Context) -> NSGlassEffectView {
+ let view = NSGlassEffectView()
+ view.style = style
+ return view
+ }
+
+ func updateNSView(_ view: NSGlassEffectView, context: Context) {
+ view.style = style
+ }
+}
+
+/// Picks the best available panel material for the running OS.
+struct AdaptivePanelBackground: View {
+ var body: some View {
+ if #available(macOS 26.0, *) {
+ LiquidGlassBackground()
+ } else {
+ VisualEffectBackground()
+ }
+ }
+}
diff --git a/Sources/ClapApp/HotKey.swift b/Sources/ClapApp/HotKey.swift
index cda0aa5..a1c51e8 100644
--- a/Sources/ClapApp/HotKey.swift
+++ b/Sources/ClapApp/HotKey.swift
@@ -72,6 +72,8 @@ struct HotKeyDefinition: Equatable, Identifiable, Sendable {
static func find(_ id: String) -> HotKeyDefinition {
presets.first(where: { $0.id == id }) ?? presets[0]
}
+
+ static let defaultID = presets[0].id
}
/// Global hotkey manager via Carbon RegisterEventHotKey.
@@ -80,19 +82,25 @@ final class HotKeyManager {
/// Invoked on the main actor when the hotkey fires.
var onHotKey: (@MainActor () -> Void)?
- private var currentDefinition: HotKeyDefinition = HotKeyDefinition.presets[0]
private var hotKeyRef: EventHotKeyRef?
private var handlerRef: EventHandlerRef?
+ private(set) var lastStatus: OSStatus = noErr
+
+ /// True when the handler is installed AND a hotkey ref is registered.
+ var isRegistered: Bool { hotKeyRef != nil && lastStatus == noErr }
+
+ var statusDescription: String {
+ "OSStatus \(lastStatus)"
+ }
static let signature: OSType = 0x434C_4150 // 'CLAP'
func register(definition: HotKeyDefinition = HotKeyDefinition.presets[0]) {
unregister()
- currentDefinition = definition
installHandlerIfNeeded()
let hotKeyID = EventHotKeyID(signature: Self.signature, id: 1)
- RegisterEventHotKey(
+ lastStatus = RegisterEventHotKey(
definition.keyCode,
definition.modifiers,
hotKeyID,
@@ -109,8 +117,10 @@ final class HotKeyManager {
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
- let selfPtr = Unmanaged.passUnretained(self).toOpaque()
- _ = InstallEventHandler(
+ // Retained for the lifetime of the handler; released in deinit right
+ // before RemoveEventHandler so the callback can never dangle.
+ let selfPtr = Unmanaged.passRetained(self).toOpaque()
+ let installStatus = InstallEventHandler(
GetEventDispatcherTarget(),
{ _, event, userData -> OSStatus in
guard let userData, let event else { return OSStatus(eventNotHandledErr) }
@@ -136,6 +146,10 @@ final class HotKeyManager {
selfPtr,
&handlerRef
)
+ if installStatus != noErr {
+ lastStatus = installStatus
+ Unmanaged.passUnretained(self).release()
+ }
}
private func fire() {
@@ -155,6 +169,7 @@ final class HotKeyManager {
unregister()
if let handlerRef {
RemoveEventHandler(handlerRef)
+ Unmanaged.passUnretained(self).release()
self.handlerRef = nil
}
}
diff --git a/Sources/ClapApp/MenuBar.swift b/Sources/ClapApp/MenuBar.swift
index 5bda2ec..c725667 100644
--- a/Sources/ClapApp/MenuBar.swift
+++ b/Sources/ClapApp/MenuBar.swift
@@ -59,7 +59,7 @@ final class MenuBarController: NSObject, NSMenuDelegate {
Task { @MainActor [weak self] in
guard let self else { return }
let recent = (try? await self.store.list(type: .text, limit: 5, offset: 0)) ?? []
- let paused = ((try? await self.store.config("monitoring.paused")) ?? "0") == "1"
+ let paused = ((try? await self.store.config(ConfigKey.monitoringPaused)) ?? "0") == "1"
let hotkeyStr = ((try? await self.store.config("ui.hotkey")) ?? "cmd+shift+v")
self.currentShortcut = HotKeyDefinition.find(hotkeyStr)
self.cachedRecent = recent
@@ -127,15 +127,7 @@ final class MenuBarController: NSObject, NSMenuDelegate {
/// Single-line preview: control chars stripped, truncated to 40 chars.
static func preview(_ text: String) -> String {
- let cleaned = text.prefix(200)
- .components(separatedBy: .whitespacesAndNewlines)
- .filter { !$0.isEmpty }
- .joined(separator: " ")
- .components(separatedBy: .controlCharacters)
- .joined()
- if cleaned.count > 40 {
- return String(cleaned.prefix(40)) + "…"
- }
+ let cleaned = TextSummaries.singleLine(String(text.prefix(200)), maxChars: 40)
return cleaned.isEmpty ? "(whitespace)" : cleaned
}
@@ -149,7 +141,7 @@ final class MenuBarController: NSObject, NSMenuDelegate {
let newPaused = !cachedPaused
cachedPaused = newPaused
Task { @MainActor [store] in
- try? await store.setConfig("monitoring.paused", value: newPaused ? "1" : "0")
+ try? await store.setConfig(ConfigKey.monitoringPaused, value: newPaused ? "1" : "0")
IPC.post(.configChanged)
}
}
diff --git a/Sources/ClapApp/Panel.swift b/Sources/ClapApp/Panel.swift
index da767e6..d55b320 100644
--- a/Sources/ClapApp/Panel.swift
+++ b/Sources/ClapApp/Panel.swift
@@ -1,5 +1,7 @@
import AppKit
+import ClapCore
import SwiftUI
+import Combine
/// Borderless nonactivating floating panel hosting the SwiftUI UI.
final class ClapPanel: NSPanel {
@@ -17,14 +19,15 @@ final class ClapPanel: NSPanel {
@MainActor
final class PanelController: NSObject, NSWindowDelegate {
- static let panelSize = NSSize(width: 780, height: 520)
- static let minPanelSize = NSSize(width: 520, height: 360)
- private static let frameConfigKey = "ui.panel_frame"
+ static let panelSize = NSSize(width: 480, height: 520)
+ static let minPanelSize = NSSize(width: 460, height: 280)
+ private static let frameConfigKey = ConfigKey.uiPanelFrame
private let panel: ClapPanel
private let appState: AppState
private var keyMonitor: Any?
- private var previewController: PreviewController?
+ private var mouseMoveMonitor: Any?
+ private var selectionCancellable: AnyCancellable?
private var previousApp: NSRunningApplication?
@@ -52,16 +55,39 @@ final class PanelController: NSObject, NSWindowDelegate {
panel.hasShadow = true
panel.hidesOnDeactivate = false
panel.isMovableByWindowBackground = true
- panel.animationBehavior = .utilityWindow
+ panel.animationBehavior = .none
panel.becomesKeyOnlyIfNeeded = false
panel.isReleasedWhenClosed = false
panel.minSize = Self.minPanelSize
+ // Required so the app receives mouseMoved events while inactive;
+ // those moves are what arm hover-selection (see AppState.pointerArmed).
+ panel.acceptsMouseMovedEvents = true
panel.delegate = self
panel.contentView = NSHostingView(rootView: ContentView().environmentObject(appState))
- previewController = PreviewController(appState: appState, parent: panel)
installKeyMonitor()
+ appState.slideout.window = panel
+
+ selectionCancellable = appState.$selectedID
+ .removeDuplicates()
+ .sink { [weak self] newID in
+ guard let self else { return }
+ let hasEntry = (newID != nil)
+ if hasEntry {
+ if self.appState.slideout.state.isOpen {
+ // Already open: stays open, preview content updates live
+ } else if self.panel.isVisible {
+ self.appState.slideout.startAutoOpen()
+ }
+ } else {
+ self.appState.slideout.cancelAutoOpen()
+ if self.appState.slideout.state.isOpen {
+ self.appState.slideout.closePreview(animated: self.panel.isVisible)
+ }
+ }
+ }
+
// Warm the saved-frame cache before the first open.
Task { [weak self] in
guard let raw = try? await appState.store.config(Self.frameConfigKey) else { return }
@@ -74,6 +100,7 @@ final class PanelController: NSObject, NSWindowDelegate {
deinit {
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
+ if let mouseMoveMonitor { NSEvent.removeMonitor(mouseMoveMonitor) }
}
var isVisible: Bool { panel.isVisible }
@@ -93,20 +120,29 @@ final class PanelController: NSObject, NSWindowDelegate {
}
appState.panelWillShow()
suppressFrameSave = true
+ var targetSize = savedFrame?.size ?? Self.panelSize
+ targetSize.width = appState.slideout.contentWidth
+
if let saved = savedFrame, frameIsOnAVisibleScreen(saved) {
// Reopen exactly where the user last dragged/resized it.
- panel.setFrame(saved, display: false)
+ var reopenFrame = saved
+ reopenFrame.size.width = targetSize.width
+ panel.setFrame(reopenFrame, display: false)
} else if let screen = screenWithMouse() {
let frame = screen.visibleFrame
- let size = savedFrame?.size ?? Self.panelSize
let origin = NSPoint(
- x: frame.midX - size.width / 2,
- y: frame.midY - size.height / 2
+ x: frame.midX - targetSize.width / 2,
+ y: frame.midY - targetSize.height / 2
)
- panel.setFrame(NSRect(origin: origin, size: size), display: false)
+ panel.setFrame(NSRect(origin: origin, size: targetSize), display: false)
}
suppressFrameSave = false
+ appState.slideout.closePreview(animated: false)
+ if appState.selectedEntry != nil {
+ appState.slideout.startAutoOpen()
+ }
panel.makeKeyAndOrderFront(nil)
+ installMouseMoveMonitor()
// Focus the search field once the panel is actually key.
Task { @MainActor [appState] in
appState.searchFocusToken += 1
@@ -115,11 +151,13 @@ final class PanelController: NSObject, NSWindowDelegate {
func hide(reactivatePreviousApp: Bool = false) {
guard panel.isVisible else { return }
- previewController?.hide()
+ appState.slideout.cancelAutoOpen()
+ appState.slideout.closePreview(animated: false)
+ removeMouseMoveMonitor()
panel.orderOut(nil)
if reactivatePreviousApp {
if let previousApp, !previousApp.isTerminated {
- previousApp.activate(options: .activateIgnoringOtherApps)
+ previousApp.activate()
} else {
NSApp.hide(nil)
}
@@ -135,11 +173,6 @@ final class PanelController: NSObject, NSWindowDelegate {
try? rep.representation(using: .png, properties: [:])?.write(to: url)
}
- /// Debug-only companion: snapshot of the preview window, if visible.
- func writePreviewSnapshot(to url: URL) {
- previewController?.writeSnapshot(to: url)
- }
-
private func screenWithMouse() -> NSScreen? {
let mouse = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main
@@ -156,12 +189,15 @@ final class PanelController: NSObject, NSWindowDelegate {
private func rememberCurrentFrame() {
guard !suppressFrameSave, panel.isVisible else { return }
- let frame = panel.frame
+ var frame = panel.frame
+ if appState.slideout.state.isOpen {
+ frame.size.width = appState.slideout.contentWidth
+ }
savedFrame = frame
// Debounced: windowDidMove fires continuously while dragging.
frameSaveTask?.cancel()
frameSaveTask = Task { [appState] in
- try? await Task.sleep(nanoseconds: 300_000_000)
+ try? await Task.sleep(nanoseconds: Timing.frameSaveDebounceNanos)
guard !Task.isCancelled else { return }
try? await appState.store.setConfig(Self.frameConfigKey,
value: NSStringFromRect(frame))
@@ -176,13 +212,18 @@ final class PanelController: NSObject, NSWindowDelegate {
func windowDidMove(_ notification: Notification) {
rememberCurrentFrame()
- // The preview may need to flip sides near a screen edge.
- previewController?.refresh()
}
func windowDidEndLiveResize(_ notification: Notification) {
+ let width = panel.frame.width
+ let slideout = appState.slideout
+ if slideout.state.isOpen {
+ slideout.contentWidth = max(slideout.minimumContentWidth,
+ width - slideout.slideoutWidth)
+ } else {
+ slideout.contentWidth = max(slideout.minimumContentWidth, width)
+ }
rememberCurrentFrame()
- previewController?.refresh()
}
// MARK: - Keyboard
@@ -194,6 +235,28 @@ final class PanelController: NSObject, NSWindowDelegate {
}
}
+ /// Arms hover-selection on the first physical pointer movement after the
+ /// panel opens. Without this, a row that happens to sit under the
+ /// stationary cursor would be selected the instant the list appears,
+ /// hijacking blind paste (Enter pastes the selection).
+ private func installMouseMoveMonitor() {
+ guard mouseMoveMonitor == nil else { return }
+ mouseMoveMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.mouseMoved, .leftMouseDragged, .otherMouseDragged]
+ ) { [weak self] event in
+ guard let self, event.window === self.panel else { return event }
+ self.appState.armPointer()
+ return event
+ }
+ }
+
+ private func removeMouseMoveMonitor() {
+ if let mouseMoveMonitor {
+ NSEvent.removeMonitor(mouseMoveMonitor)
+ self.mouseMoveMonitor = nil
+ }
+ }
+
/// True while the search field (its field editor) has keyboard focus.
private var searchFieldIsFirstResponder: Bool {
panel.firstResponder is NSText
@@ -207,37 +270,8 @@ final class PanelController: NSObject, NSWindowDelegate {
let chars = event.charactersIgnoringModifiers ?? ""
if modifiers == .command {
- switch chars {
- case "f":
- appState.searchFocusToken += 1
- return nil
- case "1":
- appState.tab = .classic
- return nil
- case "2":
- appState.tab = .media
- return nil
- case "3":
- appState.tab = .shell
- return nil
- case "4":
- appState.tab = .favs
- return nil
- case "p":
- appState.togglePinSelected()
- return nil
- case "s", "b":
- appState.toggleFavoriteSelected()
- return nil
- case "r":
- appState.regexMode.toggle()
- return nil
- case "d":
- appState.deleteSelected()
- return nil
- default:
- return event // e.g. Cmd+A/C/V handled by the Edit menu
- }
+ if handleCommandKey(chars) { return nil }
+ return event
}
// Option+Delete removes the selected entry — unless the user is
@@ -270,4 +304,32 @@ final class PanelController: NSObject, NSWindowDelegate {
return event
}
+
+ /// Handles ⌘-modified shortcuts. Returns false when unhandled so the
+ /// event passes through (e.g. Cmd+A/C/V for the Edit menu).
+ private func handleCommandKey(_ chars: String) -> Bool {
+ switch chars {
+ case "f":
+ appState.searchFocusToken += 1
+ case "1":
+ appState.selectTab(.classic)
+ case "2":
+ appState.selectTab(.shell)
+ case "3":
+ appState.selectTab(.favs)
+ case "4":
+ appState.selectTab(.media)
+ case "p":
+ appState.togglePinSelected()
+ case "s", "b":
+ appState.toggleFavoriteSelected()
+ case "r":
+ appState.setRegexMode(!appState.regexMode)
+ case "d":
+ appState.deleteSelected()
+ default:
+ return false
+ }
+ return true
+ }
}
diff --git a/Sources/ClapApp/PasteboardMonitor.swift b/Sources/ClapApp/PasteboardMonitor.swift
index 0a29dbb..24dff53 100644
--- a/Sources/ClapApp/PasteboardMonitor.swift
+++ b/Sources/ClapApp/PasteboardMonitor.swift
@@ -35,7 +35,7 @@ actor PasteboardMonitor {
private static let skippedTypes: Set = [
"org.nspasteboard.TransientType",
"org.nspasteboard.ConcealedType",
- "org.nspasteboard.AutoGeneratedType",
+ "org.nspasteboard.AutoGeneratedType"
]
init(store: ClipboardStore) {
@@ -48,7 +48,7 @@ actor PasteboardMonitor {
pollTask = Task(priority: .utility) { [weak self] in
while !Task.isCancelled {
await self?.poll()
- try? await Task.sleep(nanoseconds: 150_000_000)
+ try? await Task.sleep(nanoseconds: Timing.pasteboardPollNanos)
}
}
logger.info("pasteboard monitor started")
@@ -74,7 +74,7 @@ actor PasteboardMonitor {
/// Re-reads monitoring.paused and exclusions from the config table.
func refreshConfig() async {
- paused = ((try? await store.config("monitoring.paused")) ?? "0") == "1"
+ paused = ((try? await store.config(ConfigKey.monitoringPaused)) ?? "0") == "1"
var ids: Set = []
if let raw = (try? await store.config("exclusions")) ?? nil,
let data = raw.data(using: .utf8),
@@ -82,7 +82,8 @@ actor PasteboardMonitor {
ids = Set(array)
}
exclusions = ids
- logger.debug("config refreshed: paused=\(self.paused, privacy: .public), exclusions=\(self.exclusions.count, privacy: .public)")
+ logger.debug(
+ "config refreshed: paused=\(self.paused, privacy: .public), exclusions=\(self.exclusions.count, privacy: .public)")
}
// MARK: - Poll loop
diff --git a/Sources/ClapApp/Paster.swift b/Sources/ClapApp/Paster.swift
index 56888a0..4d8ca0b 100644
--- a/Sources/ClapApp/Paster.swift
+++ b/Sources/ClapApp/Paster.swift
@@ -3,7 +3,7 @@ import Carbon.HIToolbox
import os
/// Synthesizes a Cmd+V keystroke into the frontmost app after clap writes to
-/// the pasteboard (Maccy-style paste-on-select).
+/// the pasteboard (paste-on-select).
enum Paster {
private static let logger = Logger(subsystem: "com.spongycode.clap", category: "paste")
private static var lastPromptTime: Date?
diff --git a/Sources/ClapApp/PreviewPanel.swift b/Sources/ClapApp/PreviewPanel.swift
index 07cd67a..e8b572f 100644
--- a/Sources/ClapApp/PreviewPanel.swift
+++ b/Sources/ClapApp/PreviewPanel.swift
@@ -3,124 +3,160 @@ import SwiftUI
import Combine
import ClapCore
-/// Floating preview attached to the main panel (Maccy-style): shows the
-/// selected entry's full content (scrollable text / scaled image) plus the
-/// metadata the list can't fit — id, dates, use count, size, source app.
-///
-/// Never becomes key: the main panel hides on resignKey, so the preview must
-/// be a passive child window.
-final class ClapPreviewPanel: NSPanel {
- override var canBecomeKey: Bool { false }
- override var canBecomeMain: Bool { false }
-}
-
-@MainActor
-final class PreviewController {
-
- static let sideSize = NSSize(width: 400, height: 520)
- static let bandHeight: CGFloat = 300
- private static let gap: CGFloat = 8
-
- private let preview: ClapPreviewPanel
- private let appState: AppState
- private weak var parent: NSPanel?
- private var selectionCancellable: AnyCancellable?
- private var shownEntryKey: String?
-
- init(appState: AppState, parent: NSPanel) {
- self.appState = appState
- self.parent = parent
- preview = ClapPreviewPanel(
- contentRect: NSRect(origin: .zero, size: Self.sideSize),
- styleMask: [.borderless, .nonactivatingPanel],
- backing: .buffered,
- defer: true
- )
- preview.isFloatingPanel = true
- preview.level = .floating
- preview.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
- preview.isOpaque = false
- preview.backgroundColor = .clear
- preview.hasShadow = true
- preview.isReleasedWhenClosed = false
- preview.becomesKeyOnlyIfNeeded = true
-
- // Debounced: arrowing quickly through rows shouldn't churn previews.
- selectionCancellable = appState.$selectedID
- .removeDuplicates()
- .debounce(for: .milliseconds(200), scheduler: RunLoop.main)
- .sink { [weak self] _ in self?.refresh() }
+/// High-performance scrollable text preview using AppKit's TextKit 2 layout manager.
+/// Virtualizes long text (1,000+ chars up to megabytes) with native text selection.
+struct LargeTextPreviewView: NSViewRepresentable {
+ let text: String
+ let query: String
+ let isRegex: Bool
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
}
- /// Recomputes visibility, content, and placement for the current selection.
- func refresh() {
- guard let parent, parent.isVisible, let entry = appState.selectedEntry else {
- hide()
- return
- }
- let stateKey = "\(entry.id)-\(entry.isPinned)-\(entry.isFavorite)-\(entry.useCount)-\(entry.lastUsedAt.timeIntervalSince1970)-\(appState.trimmedQuery)-\(appState.regexMode)"
- if shownEntryKey != stateKey || preview.contentView == nil {
- shownEntryKey = stateKey
- preview.contentView = NSHostingView(
- rootView: PreviewView(entry: entry).environmentObject(appState))
- }
- place(around: parent.frame, on: parent.screen ?? NSScreen.main)
- if preview.parent == nil {
- parent.addChildWindow(preview, ordered: .above)
- }
- preview.orderFront(nil)
+ final class Coordinator {
+ var lastText: String?
+ var lastQuery: String?
+ var lastRegex: Bool?
}
- func hide() {
- shownEntryKey = nil
- preview.parent?.removeChildWindow(preview)
- preview.orderOut(nil)
- preview.contentView = nil
+ func makeNSView(context: Context) -> NSScrollView {
+ let textView = NSTextView(usingTextLayoutManager: true)
+ textView.isEditable = false
+ textView.isSelectable = true
+ textView.isRichText = false
+ textView.drawsBackground = false
+ textView.font = NSFont.monospacedSystemFont(ofSize: 12.5, weight: .regular)
+ textView.textColor = .labelColor
+ textView.textContainerInset = NSSize(width: 14, height: 10)
+ textView.isVerticallyResizable = true
+ textView.isHorizontallyResizable = false
+ textView.autoresizingMask = [.width]
+ textView.textContainer?.lineFragmentPadding = 0
+ textView.textContainer?.widthTracksTextView = true
+ textView.textContainer?.heightTracksTextView = false
+
+ let scrollView = NSScrollView()
+ scrollView.documentView = textView
+ scrollView.hasVerticalScroller = true
+ scrollView.hasHorizontalScroller = false
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .noBorder
+ scrollView.drawsBackground = false
+
+ context.coordinator.lastText = text
+ context.coordinator.lastQuery = query
+ context.coordinator.lastRegex = isRegex
+ applyText(to: textView)
+ return scrollView
}
- /// Debug-only (see AppDelegate): renders the preview's view hierarchy to
- /// a PNG for headless UI verification.
- func writeSnapshot(to url: URL) {
- guard let view = preview.contentView,
- let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
- view.cacheDisplay(in: view.bounds, to: rep)
- try? rep.representation(using: .png, properties: [:])?.write(to: url)
+ func updateNSView(_ scrollView: NSScrollView, context: Context) {
+ guard let textView = scrollView.documentView as? NSTextView else { return }
+ let coord = context.coordinator
+ if coord.lastText == text && coord.lastQuery == query && coord.lastRegex == isRegex {
+ return
+ }
+ coord.lastText = text
+ coord.lastQuery = query
+ coord.lastRegex = isRegex
+ applyText(to: textView)
}
- /// Right of the panel, else left, else centered below, else centered
- /// above — first placement that fits the visible screen area wins.
- private func place(around panelFrame: NSRect, on screen: NSScreen?) {
- guard let visible = screen?.visibleFrame else { return }
- let side = NSSize(width: Self.sideSize.width, height: panelFrame.height)
- let band = NSSize(width: panelFrame.width, height: Self.bandHeight)
-
- var frame: NSRect
- if panelFrame.maxX + Self.gap + side.width <= visible.maxX {
- frame = NSRect(x: panelFrame.maxX + Self.gap, y: panelFrame.minY,
- width: side.width, height: side.height)
- } else if panelFrame.minX - Self.gap - side.width >= visible.minX {
- frame = NSRect(x: panelFrame.minX - Self.gap - side.width, y: panelFrame.minY,
- width: side.width, height: side.height)
- } else if panelFrame.minY - Self.gap - band.height >= visible.minY {
- frame = NSRect(x: panelFrame.minX, y: panelFrame.minY - Self.gap - band.height,
- width: band.width, height: band.height)
+ private func applyText(to textView: NSTextView) {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ textView.string = text
+ textView.textColor = .labelColor
+ textView.font = NSFont.monospacedSystemFont(ofSize: 12.5, weight: .regular)
+ return
+ }
+
+ let font = NSFont.monospacedSystemFont(ofSize: 12.5, weight: .regular)
+ let defaultAttributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.labelColor
+ ]
+
+ let mutableAttr = NSMutableAttributedString(string: text, attributes: defaultAttributes)
+
+ let highlightBg = NSColor(red: 1.0, green: 0.88, blue: 0.15, alpha: 1.0)
+ let highlightFg = NSColor.black
+
+ if isRegex {
+ if let regex = try? NSRegularExpression(pattern: trimmed, options: [.caseInsensitive]) {
+ let scanLength = min((text as NSString).length, 100_000)
+ let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: scanLength))
+ for match in matches {
+ mutableAttr.addAttributes([
+ .backgroundColor: highlightBg,
+ .foregroundColor: highlightFg
+ ], range: match.range)
+ }
+ }
} else {
- frame = NSRect(x: panelFrame.minX, y: panelFrame.maxY + Self.gap,
- width: band.width, height: min(band.height,
- visible.maxY - panelFrame.maxY - Self.gap))
+ let tokens = trimmed.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
+ let nsString = text as NSString
+ let scanLength = min(nsString.length, 100_000)
+ for token in tokens {
+ var searchRange = NSRange(location: 0, length: scanLength)
+ while searchRange.location < scanLength {
+ let found = nsString.range(of: token, options: .caseInsensitive, range: searchRange)
+ if found.location != NSNotFound {
+ mutableAttr.addAttributes([
+ .backgroundColor: highlightBg,
+ .foregroundColor: highlightFg
+ ], range: found)
+ let nextLoc = found.location + found.length
+ if nextLoc >= scanLength { break }
+ searchRange = NSRange(location: nextLoc, length: scanLength - nextLoc)
+ } else {
+ break
+ }
+ }
+ }
}
- preview.setFrame(frame, display: true)
+
+ textView.textStorage?.setAttributedString(mutableAttr)
}
}
// MARK: - SwiftUI content
+/// Smart-card payloads parsed once per entry instead of on every body
+/// evaluation (JWT parsing alone runs JSONSerialization).
+private struct ParsedEntryContent {
+ var color: ParsedColor?
+ var base64Decoded: String?
+ var urlDecoded: String?
+ var jwt: JWTData?
+ var epoch: EpochData?
+
+ var hasCards: Bool {
+ color != nil || base64Decoded != nil || urlDecoded != nil || jwt != nil || epoch != nil
+ }
+
+ static let empty = ParsedEntryContent()
+
+ static func parse(_ content: String?) -> ParsedEntryContent {
+ guard let content, !content.isEmpty, content.count <= 20_000 else { return .empty }
+ return ParsedEntryContent(
+ color: ColorParser.parse(content),
+ base64Decoded: TextTransformer.decodeBase64(content),
+ urlDecoded: TextTransformer.decodeURL(content),
+ jwt: JWTData.parse(content),
+ epoch: EpochData.parse(content)
+ )
+ }
+}
+
struct PreviewView: View {
@EnvironmentObject private var state: AppState
let entry: ClipboardEntry
@State private var image: NSImage?
@State private var idCopied = false
+ @State private var parsed: ParsedEntryContent = .empty
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -130,406 +166,78 @@ struct PreviewView: View {
.padding(14)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .background(VisualEffectBackground())
- .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)
- )
+ .task(id: entry.id) {
+ parsed = await Task.detached(priority: .userInitiated) {
+ ParsedEntryContent.parse(entry.content)
+ }.value
+ if entry.type == .image {
+ image = await state.fullImage(for: entry)
+ }
+ }
}
+ // MARK: Content section
+
@ViewBuilder
private var contentSection: some View {
if entry.type == .text || entry.type == .shell {
- ScrollView([.vertical]) {
- VStack(alignment: .leading, spacing: 12) {
- if let parsedColor = ColorParser.parse(entry.content) {
- HStack(spacing: 12) {
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color(nsColor: parsedColor))
- .frame(width: 46, height: 46)
- .overlay(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.18), lineWidth: 1)
- )
- .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
-
- VStack(alignment: .leading, spacing: 3) {
- Text("Color Preview")
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(.primary)
- Text(entry.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
- .font(.system(size: 12, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- Spacer()
+ VStack(alignment: .leading, spacing: 0) {
+ if parsed.hasCards {
+ VStack(alignment: .leading, spacing: 10) {
+ if let color = parsed.color {
+ ColorCardView(color: color, source: entry.content ?? "")
}
- .padding(10)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.primary.opacity(0.04))
- )
- }
-
- if let content = entry.content, let decodedB64 = TextTransformer.decodeBase64(content) {
- HStack(alignment: .top, spacing: 10) {
- Image(systemName: "doc.text.magnifyingglass")
- .font(.system(size: 15, weight: .medium))
- .foregroundStyle(.blue)
- .frame(width: 22, height: 22)
-
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text("Base64 Decoded")
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(.primary)
- Spacer()
- Button {
- state.copyTransformedText(decodedB64)
- } label: {
- Label("Copy Decoded", systemImage: "doc.on.doc")
- .font(.system(size: 10))
- }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
- Text(decodedB64)
- .font(.system(size: 11.5, design: .monospaced))
- .lineLimit(3)
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- }
+ if let decoded = parsed.base64Decoded {
+ DecodedCardView(icon: "doc.text.magnifyingglass",
+ tint: .blue,
+ title: "Base64 Decoded",
+ decoded: decoded) { state.copyTransformedText(decoded) }
}
- .padding(10)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.blue.opacity(0.06))
- )
- }
-
- if let content = entry.content, let decodedURL = TextTransformer.decodeURL(content) {
- HStack(alignment: .top, spacing: 10) {
- Image(systemName: "link")
- .font(.system(size: 15, weight: .medium))
- .foregroundStyle(.teal)
- .frame(width: 22, height: 22)
-
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text("URL Decoded")
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(.primary)
- Spacer()
- Button {
- state.copyTransformedText(decodedURL)
- } label: {
- Label("Copy Decoded", systemImage: "doc.on.doc")
- .font(.system(size: 10))
- }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
- Text(decodedURL)
- .font(.system(size: 11.5, design: .monospaced))
- .lineLimit(3)
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- }
+ if let decoded = parsed.urlDecoded {
+ DecodedCardView(icon: "link",
+ tint: .teal,
+ title: "URL Decoded",
+ decoded: decoded) { state.copyTransformedText(decoded) }
}
- .padding(10)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.teal.opacity(0.06))
- )
- }
-
- if let content = entry.content, let jwt = JWTData.parse(content) {
- VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 8) {
- Image(systemName: "key.horizontal.fill")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.indigo)
- Text("JWT Inspector")
- .font(.system(size: 12.5, weight: .bold))
- .foregroundStyle(.primary)
-
- Text(jwt.algorithm)
- .font(.system(size: 10, weight: .semibold, design: .monospaced))
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(
- Capsule()
- .fill(Color.primary.opacity(0.08))
- )
-
- if let isExp = jwt.isExpired {
- HStack(spacing: 3) {
- Circle()
- .fill(isExp ? Color.red : Color.green)
- .frame(width: 6, height: 6)
- Text(isExp ? "Expired" : "Valid")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(isExp ? .red : .green)
- }
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(
- Capsule()
- .fill((isExp ? Color.red : Color.green).opacity(0.12))
- )
- }
-
- Spacer()
-
- Menu {
- Button("Copy Payload JSON") {
- state.copyTransformedText(jwt.payloadJSON)
- }
- Button("Copy Header JSON") {
- state.copyTransformedText(jwt.headerJSON)
- }
- } label: {
- Label("Copy JSON", systemImage: "doc.on.doc")
- .font(.system(size: 10))
- }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
-
- if jwt.subject != nil || jwt.issuer != nil || jwt.expirationDate != nil {
- VStack(alignment: .leading, spacing: 3) {
- if let sub = jwt.subject {
- HStack(spacing: 6) {
- Text("Subject:")
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(.secondary)
- Text(sub)
- .font(.system(size: 11, design: .monospaced))
- .lineLimit(1)
- }
- }
- if let iss = jwt.issuer {
- HStack(spacing: 6) {
- Text("Issuer:")
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(.secondary)
- Text(iss)
- .font(.system(size: 11, design: .monospaced))
- .lineLimit(1)
- }
- }
- if let expDate = jwt.expirationDate {
- HStack(spacing: 6) {
- Text("Expires:")
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(.secondary)
- Text(Self.dateFormatter.string(from: expDate))
- .font(.system(size: 11))
- }
- }
- }
- }
-
- Divider()
-
- Text("Decoded Payload:")
- .font(.system(size: 10.5, weight: .semibold))
- .foregroundStyle(.secondary)
-
- Text(jwt.payloadJSON)
- .font(.system(size: 11, design: .monospaced))
- .textSelection(.enabled)
- .padding(8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.primary.opacity(0.04))
- )
+ if let jwt = parsed.jwt {
+ JWTCardView(jwt: jwt) { text in state.copyTransformedText(text) }
}
- .padding(12)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color.indigo.opacity(0.06))
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(Color.indigo.opacity(0.18), lineWidth: 1)
- )
- )
- }
-
- if let content = entry.content, let epoch = EpochData.parse(content) {
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .center, spacing: 8) {
- Image(systemName: "clock.badge.checkmark.fill")
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(.orange)
-
- VStack(alignment: .leading, spacing: 3) {
- Text("Epoch Timestamp")
- .font(.system(size: 12.5, weight: .bold))
- .foregroundStyle(.primary)
-
- Text(epoch.unitDescription)
- .font(.system(size: 10, weight: .semibold, design: .monospaced))
- .lineLimit(1)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(
- Capsule()
- .fill(Color.orange.opacity(0.12))
- )
- }
-
- Spacer()
-
- Menu {
- Button("Copy ISO 8601 (\(epoch.iso8601))") {
- state.copyTransformedText(epoch.iso8601)
- }
- Button("Copy Local Date") {
- state.copyTransformedText(epoch.localFormatted)
- }
- if epoch.unitDescription.contains("Seconds") {
- Button("Copy as Milliseconds (\(epoch.unixMillis))") {
- state.copyTransformedText(String(epoch.unixMillis))
- }
- } else {
- Button("Copy as Seconds (\(epoch.unixSeconds))") {
- state.copyTransformedText(String(epoch.unixSeconds))
- }
- }
- } label: {
- Label("Copy Date", systemImage: "doc.on.doc")
- .font(.system(size: 10))
- }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
-
- VStack(alignment: .leading, spacing: 5) {
- Text(epoch.localFormatted)
- .font(.system(size: 12.5, weight: .medium))
- .foregroundStyle(.primary)
- .textSelection(.enabled)
-
- HStack(spacing: 5) {
- Text("UTC:")
- .font(.system(size: 10.5, weight: .semibold))
- .foregroundStyle(.secondary)
- Text(epoch.iso8601)
- .font(.system(size: 11, design: .monospaced))
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- }
-
- HStack(spacing: 5) {
- Text("Relative:")
- .font(.system(size: 10.5, weight: .semibold))
- .foregroundStyle(.secondary)
- Text(epoch.relativeFormatted)
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- }
- }
- .padding(10)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.primary.opacity(0.04))
- )
+ if let epoch = parsed.epoch {
+ EpochCardView(epoch: epoch) { text in state.copyTransformedText(text) }
}
- .padding(12)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color.orange.opacity(0.06))
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(Color.orange.opacity(0.18), lineWidth: 1)
- )
- )
}
-
- Text(highlightedDisplayedText)
- .font(.system(size: 13, design: .monospaced))
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding(.horizontal, 14)
+ .padding(.top, 12)
+ .padding(.bottom, 6)
}
- .padding(14)
- }
- } else {
- ScrollView([.vertical]) {
- VStack(spacing: 12) {
- ZStack {
- if let image {
- Image(nsImage: image)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5)
- )
- } else {
- ProgressView()
- .frame(height: 140)
- }
- }
- .frame(maxWidth: .infinity)
- .padding(.top, 4)
-
- if let ocrText = entry.content, !ocrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- VStack(alignment: .leading, spacing: 6) {
- HStack {
- Label("Extracted Text (OCR)", systemImage: "doc.text.viewfinder")
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(.primary)
- Spacer()
- Button {
- state.copyTransformedText(ocrText)
- } label: {
- Label("Copy Text", systemImage: "doc.on.doc")
- .font(.system(size: 10))
- }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
- Text(ocrText)
- .font(.system(size: 11.5, design: .monospaced))
- .foregroundStyle(.secondary)
+ if let content = entry.content {
+ if content.count >= 1_000 {
+ LargeTextPreviewView(text: content,
+ query: state.trimmedQuery,
+ isRegex: state.regexMode)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView([.vertical]) {
+ Text(highlightedDisplayedText)
+ .font(.system(size: 13, design: .monospaced))
.textSelection(.enabled)
- .padding(8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.primary.opacity(0.04))
- )
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
}
- .padding(10)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.primary.opacity(0.03))
- .overlay(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)
- )
- )
}
}
- .padding(14)
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .task(id: entry.id) {
- let store = state.store
- let url = await store.imageFileURL(for: entry)
- image = await Task.detached(priority: .userInitiated) { () -> NSImage? in
- guard let url else { return nil }
- return NSImage(contentsOf: url)
- }.value
+ } else {
+ ImageContentView(entry: entry, image: image) { text in
+ state.copyTransformedText(text)
}
}
}
+ // MARK: Metadata section
+
private var metadataSection: some View {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) {
GridRow {
@@ -546,88 +254,22 @@ struct PreviewView: View {
.controlSize(.small)
.help("Copy recognized OCR text from this image")
}
- if (entry.type == .text || entry.type == .shell),
- let content = entry.content, content.count <= 10_000 {
+ if entry.type == .text || entry.type == .shell,
+ let content = entry.content, content.count <= TextTransformer.maxTransformLength {
Menu {
- if let epoch = EpochData.parse(content) {
- Section("Timestamp") {
- Button("Copy ISO 8601 Date (\(epoch.iso8601))") {
- state.copyTransformedText(epoch.iso8601)
- }
- Button("Copy Local Date") {
- state.copyTransformedText(epoch.localFormatted)
- }
- if epoch.unitDescription.contains("Seconds") {
- Button("Copy as Milliseconds (\(epoch.unixMillis))") {
- state.copyTransformedText(String(epoch.unixMillis))
- }
- } else {
- Button("Copy as Seconds (\(epoch.unixSeconds))") {
- state.copyTransformedText(String(epoch.unixSeconds))
- }
- }
- }
- }
- if let jwt = JWTData.parse(content) {
- Section("JWT Token") {
- Button("Copy Payload JSON") {
- state.copyTransformedText(jwt.payloadJSON)
- }
- Button("Copy Header JSON") {
- state.copyTransformedText(jwt.headerJSON)
- }
- }
- }
- if content.count <= 1000 {
- Section("Text Case") {
- ForEach(CaseConverter.CaseStyle.allCases) { style in
- Button {
- let converted = CaseConverter.convert(content, to: style)
- state.copyTransformedText(converted)
- } label: {
- Text("\(style.rawValue) (\(CaseConverter.convert(content, to: style).prefix(16))…)")
- }
- }
- }
- }
- Section("Encode / Decode") {
- Button {
- let converted = TextTransformer.encodeBase64(content)
- state.copyTransformedText(converted)
- } label: {
- Text("Base64 Encode")
- }
- if let decoded = TextTransformer.decodeBase64(content) {
- Button {
- state.copyTransformedText(decoded)
- } label: {
- Text("Base64 Decode (\(decoded.prefix(16))…)")
- }
- }
- Button {
- let converted = TextTransformer.encodeURL(content)
- state.copyTransformedText(converted)
- } label: {
- Text("URL Encode")
- }
- if let decoded = TextTransformer.decodeURL(content) {
- Button {
- state.copyTransformedText(decoded)
- } label: {
- Text("URL Decode (\(decoded.prefix(16))…)")
- }
- }
+ TransformMenuContent(content: content) { transformed in
+ state.copyTransformedText(transformed)
}
} label: {
Label("Copy as…", systemImage: "textformat")
.font(.system(size: 11))
}
- .menuStyle(.borderedButton)
+ .menuStyle(.button)
.controlSize(.small)
.help("Convert text case or encode/decode and copy directly to clipboard")
}
- if (entry.type == .text || entry.type == .shell) {
+ if entry.type == .text || entry.type == .shell {
Button {
state.promptSetShortcut(entry)
} label: {
@@ -637,9 +279,20 @@ struct PreviewView: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
- .help(entry.shortcut != nil ? "Edit snippet expansion shortcut (\(entry.shortcut!))" : "Assign a text abbreviation (e.g. ;email) to auto-expand this snippet")
+ .help("Assign or edit a text abbreviation (e.g. ;email) that auto-expands this snippet")
}
+ Button {
+ state.promptManageTags(entry)
+ } label: {
+ Label(entry.tags.isEmpty ? "Tags" : "\(entry.tags.count) Tags",
+ systemImage: entry.tags.isEmpty ? "tag" : "tag.fill")
+ .font(.system(size: 11))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .help("Manage tags and custom pinboards for this entry")
+
Button {
copyID()
} label: {
@@ -657,6 +310,17 @@ struct PreviewView: View {
Text(entryTypeDescription)
.font(.system(size: 12))
}
+ if entry.type == .image, let image {
+ let rep = image.representations.first
+ let w = rep?.pixelsWide ?? Int(image.size.width)
+ let h = rep?.pixelsHigh ?? Int(image.size.height)
+ GridRow {
+ metaLabel("Dimensions")
+ Text("\(w) × \(h) px")
+ .font(.system(size: 12))
+ .monospacedDigit()
+ }
+ }
GridRow {
metaLabel("Size")
Text(ByteSize.format(entry.sizeBytes)).font(.system(size: 12))
@@ -676,9 +340,24 @@ struct PreviewView: View {
if let app = entry.sourceApp {
GridRow {
metaLabel("From")
- Text(Self.appDisplayName(bundleID: app))
- .font(.system(size: 12))
- .help(app)
+ HStack(spacing: 6) {
+ AppIconView(bundleID: app, size: 14)
+ Text(Self.appDisplayName(bundleID: app))
+ .font(.system(size: 12))
+ .help(app)
+ }
+ }
+ }
+ if !entry.tags.isEmpty {
+ GridRow(alignment: .top) {
+ metaLabel("Tags")
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 4) {
+ ForEach(entry.tags, id: \.self) { tag in
+ TagPillView(tag: tag)
+ }
+ }
+ }
}
}
if entry.isPinned {
@@ -700,6 +379,8 @@ struct PreviewView: View {
}
}
+ // MARK: Helpers
+
private var highlightedDisplayedText: AttributedString {
SearchHighlighter.highlight(
text: displayedText,
@@ -717,7 +398,8 @@ struct PreviewView: View {
let prefix = content.prefix(maxPreviewChars)
let totalFormatted = NumberFormatter.localizedString(from: NSNumber(value: content.count), number: .decimal)
let previewFormatted = NumberFormatter.localizedString(from: NSNumber(value: maxPreviewChars), number: .decimal)
- return "\(prefix)\n\n⋯ [Preview truncated: showing first \(previewFormatted) of \(totalFormatted) characters. Copying or pasting will include the entire text.]"
+ return "\(prefix)\n\n⋯ [Preview truncated: showing first \(previewFormatted) of \(totalFormatted) characters. "
+ + "Copying or pasting will include the entire text.]"
}
private var entryTypeDescription: String {
@@ -743,7 +425,7 @@ struct PreviewView: View {
pasteboard.setString("", forType: NSPasteboard.PasteboardType("org.nspasteboard.TransientType"))
idCopied = true
Task { @MainActor in
- try? await Task.sleep(nanoseconds: 1_500_000_000)
+ try? await Task.sleep(nanoseconds: Timing.copiedResetNanos)
idCopied = false
}
}
@@ -762,3 +444,367 @@ struct PreviewView: View {
return FileManager.default.displayName(atPath: url.path)
}
}
+
+// MARK: - Feature cards
+
+private struct ColorCardView: View {
+ let color: ParsedColor
+ let source: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color(red: color.red, green: color.green, blue: color.blue,
+ opacity: color.alpha))
+ .frame(width: 46, height: 46)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.18), lineWidth: 1)
+ )
+ .shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Color Preview")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.primary)
+ Text(source.trimmingCharacters(in: .whitespacesAndNewlines))
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.primary.opacity(AppAlpha.Fill.subtle))
+ )
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Color preview: \(source)")
+ }
+}
+
+private struct DecodedCardView: View {
+ let icon: String
+ let tint: Color
+ let title: String
+ let decoded: String
+ let onCopy: () -> Void
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: icon)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(tint)
+ .frame(width: 22, height: 22)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(title)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ Button(action: onCopy) {
+ Label("Copy Decoded", systemImage: "doc.on.doc")
+ .font(.system(size: 10))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ }
+ Text(decoded)
+ .font(.system(size: 11.5, design: .monospaced))
+ .lineLimit(3)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(tint.opacity(0.06))
+ )
+ }
+}
+
+private struct JWTCardView: View {
+ @EnvironmentObject private var state: AppState
+ let jwt: JWTData
+ let onCopy: (String) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ Image(systemName: "key.horizontal.fill")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.indigo)
+ Text("JWT Inspector")
+ .font(.system(size: 12.5, weight: .bold))
+ .foregroundStyle(.primary)
+
+ Text(jwt.algorithm)
+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ Capsule()
+ .fill(Color.primary.opacity(0.08))
+ )
+
+ if let isExp = jwt.isExpired {
+ HStack(spacing: 3) {
+ Circle()
+ .fill(isExp ? Color.red : Color.green)
+ .frame(width: 6, height: 6)
+ Text(isExp ? "Expired" : "Valid")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(isExp ? .red : .green)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ Capsule()
+ .fill((isExp ? Color.red : Color.green).opacity(0.12))
+ )
+ }
+
+ Spacer()
+
+ Menu {
+ Button("Copy Payload JSON") { onCopy(jwt.payloadJSON) }
+ Button("Copy Header JSON") { onCopy(jwt.headerJSON) }
+ } label: {
+ Label("Copy JSON", systemImage: "doc.on.doc")
+ .font(.system(size: 10))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ }
+
+ if jwt.subject != nil || jwt.issuer != nil || jwt.expirationDate != nil {
+ VStack(alignment: .leading, spacing: 3) {
+ if let sub = jwt.subject {
+ claimRow(label: "Subject:", value: sub, monospaced: true)
+ }
+ if let iss = jwt.issuer {
+ claimRow(label: "Issuer:", value: iss, monospaced: true)
+ }
+ if let expDate = jwt.expirationDate {
+ HStack(spacing: 6) {
+ Text("Expires:")
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(.secondary)
+ Text(Self.dateFormatter.string(from: expDate))
+ .font(.system(size: 11))
+ }
+ }
+ }
+ }
+
+ Divider()
+
+ Text("Decoded Payload:")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ Text(jwt.payloadJSON)
+ .font(.system(size: 11, design: .monospaced))
+ .textSelection(.enabled)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.primary.opacity(AppAlpha.Fill.subtle))
+ )
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color.indigo.opacity(0.06))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(Color.indigo.opacity(0.18), lineWidth: 1)
+ )
+ )
+ }
+
+ private func claimRow(label: String, value: String, monospaced: Bool) -> some View {
+ HStack(spacing: 6) {
+ Text(label)
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(.system(size: 11, design: monospaced ? .monospaced : .default))
+ .lineLimit(1)
+ }
+ }
+
+ private static let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter
+ }()
+}
+
+private struct EpochCardView: View {
+ @EnvironmentObject private var state: AppState
+ let epoch: EpochData
+ let onCopy: (String) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .center, spacing: 8) {
+ Image(systemName: "clock.badge.checkmark.fill")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(.orange)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Epoch Timestamp")
+ .font(.system(size: 12.5, weight: .bold))
+ .foregroundStyle(.primary)
+
+ Text(epoch.unitDescription)
+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
+ .lineLimit(1)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ Capsule()
+ .fill(Color.orange.opacity(0.12))
+ )
+ }
+
+ Spacer()
+
+ Menu {
+ Button("Copy ISO 8601 (\(epoch.iso8601))") { onCopy(epoch.iso8601) }
+ Button("Copy Local Date") { onCopy(epoch.localFormatted) }
+ if epoch.unitDescription.contains("Seconds") {
+ Button("Copy as Milliseconds (\(epoch.unixMillis))") {
+ onCopy(String(epoch.unixMillis))
+ }
+ } else {
+ Button("Copy as Seconds (\(epoch.unixSeconds))") {
+ onCopy(String(epoch.unixSeconds))
+ }
+ }
+ } label: {
+ Label("Copy Date", systemImage: "doc.on.doc")
+ .font(.system(size: 10))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ }
+
+ VStack(alignment: .leading, spacing: 5) {
+ Text(epoch.localFormatted)
+ .font(.system(size: 12.5, weight: .medium))
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+
+ HStack(spacing: 5) {
+ Text("UTC:")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(.secondary)
+ Text(epoch.iso8601)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+
+ HStack(spacing: 5) {
+ Text("Relative:")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(.secondary)
+ Text(epoch.relativeFormatted)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.primary.opacity(AppAlpha.Fill.subtle))
+ )
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color.orange.opacity(0.06))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(Color.orange.opacity(0.18), lineWidth: 1)
+ )
+ )
+ }
+}
+
+private struct ImageContentView: View {
+ let entry: ClipboardEntry
+ let image: NSImage?
+ let onCopyText: (String) -> Void
+
+ var body: some View {
+ ScrollView([.vertical]) {
+ VStack(spacing: 12) {
+ ZStack {
+ if let image {
+ Image(nsImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder), lineWidth: 0.5)
+ )
+ } else {
+ ProgressView()
+ .frame(height: 140)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .accessibilityLabel("Image preview, \(entry.imageFormat?.uppercased() ?? "unknown format")")
+ .padding(.top, 4)
+
+ if let ocrText = entry.content, !ocrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Label("Extracted Text (OCR)", systemImage: "doc.text.viewfinder")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ Button { onCopyText(ocrText) } label: {
+ Label("Copy Text", systemImage: "doc.on.doc")
+ .font(.system(size: 10))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ }
+
+ Text(ocrText)
+ .font(.system(size: 11.5, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.primary.opacity(AppAlpha.Fill.subtle))
+ )
+ }
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.primary.opacity(0.03))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)
+ )
+ )
+ }
+ }
+ .padding(14)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/Sources/ClapApp/RowViews.swift b/Sources/ClapApp/RowViews.swift
index cb2cae6..75adae7 100644
--- a/Sources/ClapApp/RowViews.swift
+++ b/Sources/ClapApp/RowViews.swift
@@ -11,72 +11,50 @@ struct EntryRow: View {
private var isSelected: Bool { state.selectedID == entry.id }
var body: some View {
- HStack(spacing: 11) {
- if entry.type == .image {
- ThumbnailView(entry: entry)
- .frame(width: 44, height: 30)
- .clipShape(RoundedRectangle(cornerRadius: 4))
- } else if entry.type == .shell {
- Image(systemName: "terminal")
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(.secondary)
- .frame(width: 20)
- } else if let parsedColor = ColorParser.parse(entry.content) {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(Color(nsColor: parsedColor))
- .frame(width: 16, height: 16)
- .overlay(
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.20), lineWidth: 1)
- )
- .shadow(color: Color.black.opacity(0.12), radius: 1, x: 0, y: 0.5)
- } else if JWTData.parse(entry.content) != nil {
- Image(systemName: "key.horizontal.fill")
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(.indigo)
- .frame(width: 18)
- } else if EpochData.parse(entry.content) != nil {
- Image(systemName: "clock.arrow.circlepath")
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(.orange)
- .frame(width: 18)
- }
+ HStack(spacing: 9) {
+ leadingIcon
Text(highlightedPreview)
.lineLimit(1)
.truncationMode(.tail)
.font(entry.type == .shell ? .system(size: 13, design: .monospaced) : .system(size: 14))
- Spacer(minLength: 8)
- if let shortcut = entry.shortcut, !shortcut.isEmpty {
- Text(shortcut)
- .font(.system(size: 11, weight: .semibold, design: .monospaced))
- .foregroundStyle(.purple)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(
- Capsule()
- .fill(Color.purple.opacity(0.12))
- .overlay(
- Capsule()
- .strokeBorder(Color.purple.opacity(0.25), lineWidth: 0.5)
- )
- )
- }
- if entry.isPinned {
- Image(systemName: "pin.fill")
- .font(.system(size: 12.5))
- .foregroundStyle(.orange)
- }
- if entry.isFavorite {
- Image(systemName: "heart.fill")
- .font(.system(size: 12.5))
- .foregroundStyle(.red)
+
+ Spacer(minLength: 6)
+
+ HStack(spacing: 6) {
+ if let shortcut = entry.shortcut, !shortcut.isEmpty {
+ Text(shortcut)
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.purple)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1.5)
+ .background(
+ Capsule()
+ .fill(Color.purple.opacity(0.12))
+ .overlay(
+ Capsule()
+ .strokeBorder(Color.purple.opacity(0.25), lineWidth: 0.5)
+ )
+ )
+ }
+ ForEach(entry.tags.prefix(2), id: \.self) { tag in
+ TagPillView(tag: tag)
+ }
+ if entry.isPinned {
+ Image(systemName: "pin.fill")
+ .font(.system(size: 11.5))
+ .foregroundStyle(.orange)
+ }
+ if entry.isFavorite {
+ Image(systemName: "heart.fill")
+ .font(.system(size: 11.5))
+ .foregroundStyle(.red)
+ }
+ Text(TextSummaries.relativeTime(entry.lastUsedAt, now: Date()))
+ .font(.system(size: 11.5))
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ .lineLimit(1)
}
- Text(RelativeTime.string(for: entry.lastUsedAt))
- .font(.system(size: 11.5))
- .foregroundStyle(.secondary)
- .monospacedDigit()
- .frame(width: 48, alignment: .trailing)
- .lineLimit(1)
}
.padding(.horizontal, 11)
.padding(.vertical, 7.5)
@@ -91,89 +69,52 @@ struct EntryRow: View {
.contentShape(Rectangle())
.onTapGesture { state.copy(entry) }
.onHover { hovering in
- if hovering { state.selectFromPointer(entry.id) }
- }
- .contextMenu {
- Button("Copy") { state.copy(entry) }
- if (entry.type == .text || entry.type == .shell) {
- Button(entry.shortcut == nil ? "Set Snippet Shortcut…" : "Edit Snippet Shortcut (\(entry.shortcut!))…") {
- state.promptSetShortcut(entry)
- }
- }
- if (entry.type == .text || entry.type == .shell),
- let content = entry.content, content.count <= 10_000 {
- Menu("Copy As") {
- if let epoch = EpochData.parse(content) {
- Section("Timestamp") {
- Button("Copy ISO 8601 Date") {
- state.copyTransformedText(epoch.iso8601)
- }
- Button("Copy Local Formatted Date") {
- state.copyTransformedText(epoch.localFormatted)
- }
- if epoch.unitDescription.contains("Seconds") {
- Button("Copy as Milliseconds (\(epoch.unixMillis))") {
- state.copyTransformedText(String(epoch.unixMillis))
- }
- } else {
- Button("Copy as Seconds (\(epoch.unixSeconds))") {
- state.copyTransformedText(String(epoch.unixSeconds))
- }
- }
- }
- }
- if let jwt = JWTData.parse(content) {
- Section("JWT Token") {
- Button("Copy Payload JSON") {
- state.copyTransformedText(jwt.payloadJSON)
- }
- Button("Copy Header JSON") {
- state.copyTransformedText(jwt.headerJSON)
- }
- }
- }
- if content.count <= 1000 {
- Section("Text Case") {
- ForEach(CaseConverter.CaseStyle.allCases) { style in
- Button(style.rawValue) {
- let converted = CaseConverter.convert(content, to: style)
- state.copyTransformedText(converted)
- }
- }
- }
- }
- Section("Encode / Decode") {
- Button("Base64 Encode") {
- state.copyTransformedText(TextTransformer.encodeBase64(content))
- }
- if let decoded = TextTransformer.decodeBase64(content) {
- Button("Base64 Decode") {
- state.copyTransformedText(decoded)
- }
- }
- Button("URL Encode") {
- state.copyTransformedText(TextTransformer.encodeURL(content))
- }
- if let decoded = TextTransformer.decodeURL(content) {
- Button("URL Decode") {
- state.copyTransformedText(decoded)
- }
- }
- }
- }
- }
- if entry.type == .image, let ocrText = entry.content, !ocrText.isEmpty {
- Button("Copy Extracted Text") { state.copyTransformedText(ocrText) }
- }
- Button(entry.isFavorite ? "Remove from Favs" : "Add to Favs") { state.toggleFavorite(entry) }
- Button(entry.isPinned ? "Unpin" : "Pin") { state.togglePin(entry) }
- Divider()
- Button("Delete", role: .destructive) { state.delete(entry) }
+ state.hoverChanged(entry.id, hovering: hovering)
}
+ .contextMenu { EntryContextMenu(entry: entry) }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Copy clipboard entry: \(preview)")
+ .accessibilityHint("Shows a context menu with more actions")
+ .accessibilityAddTraits(.isButton)
+ .accessibilityIdentifier("entry-row.\(entry.id)")
.onAppear { state.loadMoreIfNeeded(entry) }
.id("\(entry.id)-\(entry.isPinned)-\(entry.isFavorite)")
}
+ @ViewBuilder
+ private var leadingIcon: some View {
+ if entry.type == .image {
+ ThumbnailView(entry: entry)
+ .frame(width: 44, height: 30)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ } else if entry.type == .shell {
+ Image(systemName: "terminal")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(width: 20)
+ } else if let content = entry.content, content.count <= 100, let parsed = ColorParser.parse(content) {
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color(red: parsed.red, green: parsed.green, blue: parsed.blue,
+ opacity: parsed.alpha))
+ .frame(width: 16, height: 16)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.20), lineWidth: 1)
+ )
+ .shadow(color: Color.black.opacity(0.12), radius: 1, x: 0, y: 0.5)
+ } else if let content = entry.content, content.count <= 20_000, JWTData.parse(content) != nil {
+ Image(systemName: "key.horizontal.fill")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.indigo)
+ .frame(width: 18)
+ } else if let content = entry.content, content.count <= 50, EpochData.parse(content) != nil {
+ Image(systemName: "clock.arrow.circlepath")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.orange)
+ .frame(width: 18)
+ }
+ }
+
private var highlightedPreview: AttributedString {
SearchHighlighter.highlight(
text: preview,
@@ -185,18 +126,10 @@ struct EntryRow: View {
private var preview: String {
switch entry.type {
case .text, .shell:
- let content = entry.content ?? ""
- // Collapse whitespace/newlines into a single-line preview.
- return content.prefix(500)
- .components(separatedBy: .whitespacesAndNewlines)
- .filter { !$0.isEmpty }
- .joined(separator: " ")
+ return TextSummaries.singleLine(entry.content ?? "", maxChars: 500)
case .image:
if let ocrText = entry.content, !ocrText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- return ocrText.prefix(500)
- .components(separatedBy: .whitespacesAndNewlines)
- .filter { !$0.isEmpty }
- .joined(separator: " ")
+ return TextSummaries.singleLine(ocrText, maxChars: 500)
}
let format = entry.imageFormat?.uppercased() ?? "IMAGE"
return "\(format) image · \(ByteSize.format(entry.sizeBytes))"
@@ -204,409 +137,130 @@ struct EntryRow: View {
}
}
-// MARK: - Color code parser
-
-enum ColorParser {
- static func parse(_ raw: String?) -> NSColor? {
- guard let raw else { return nil }
- let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard text.count >= 4 && text.count <= 40 else { return nil }
-
- // Hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA, 0xRRGGBB
- if text.hasPrefix("#") || text.hasPrefix("0x") {
- let hex = text.hasPrefix("#") ? String(text.dropFirst()) : String(text.dropFirst(2))
- guard let intVal = UInt64(hex, radix: 16) else { return nil }
- switch hex.count {
- case 3: // RGB
- let r = CGFloat((intVal >> 8) & 0xF) / 15.0
- let g = CGFloat((intVal >> 4) & 0xF) / 15.0
- let b = CGFloat(intVal & 0xF) / 15.0
- return NSColor(red: r, green: g, blue: b, alpha: 1.0)
- case 4: // RGBA
- let r = CGFloat((intVal >> 12) & 0xF) / 15.0
- let g = CGFloat((intVal >> 8) & 0xF) / 15.0
- let b = CGFloat((intVal >> 4) & 0xF) / 15.0
- let a = CGFloat(intVal & 0xF) / 15.0
- return NSColor(red: r, green: g, blue: b, alpha: a)
- case 6: // RRGGBB
- let r = CGFloat((intVal >> 16) & 0xFF) / 255.0
- let g = CGFloat((intVal >> 8) & 0xFF) / 255.0
- let b = CGFloat(intVal & 0xFF) / 255.0
- return NSColor(red: r, green: g, blue: b, alpha: 1.0)
- case 8: // RRGGBBAA
- let r = CGFloat((intVal >> 24) & 0xFF) / 255.0
- let g = CGFloat((intVal >> 16) & 0xFF) / 255.0
- let b = CGFloat((intVal >> 8) & 0xFF) / 255.0
- let a = CGFloat(intVal & 0xFF) / 255.0
- return NSColor(red: r, green: g, blue: b, alpha: a)
- default:
- return nil
- }
- }
-
- let lower = text.lowercased()
- // rgb(...) or rgba(...)
- if lower.hasPrefix("rgb(") || lower.hasPrefix("rgba(") {
- let inner = lower.replacingOccurrences(of: "rgba(", with: "")
- .replacingOccurrences(of: "rgb(", with: "")
- .replacingOccurrences(of: ")", with: "")
- let parts = inner.split(whereSeparator: { $0 == "," || $0 == " " || $0 == "/" })
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- if parts.count >= 3 {
- guard let r = Double(parts[0]),
- let g = Double(parts[1]),
- let b = Double(parts[2]) else { return nil }
- let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
- return NSColor(red: CGFloat(max(0, min(255, r)) / 255.0),
- green: CGFloat(max(0, min(255, g)) / 255.0),
- blue: CGFloat(max(0, min(255, b)) / 255.0),
- alpha: CGFloat(max(0, min(1.0, a))))
- }
- }
-
- // hsl(...) or hsla(...)
- if lower.hasPrefix("hsl(") || lower.hasPrefix("hsla(") {
- let inner = lower.replacingOccurrences(of: "hsla(", with: "")
- .replacingOccurrences(of: "hsl(", with: "")
- .replacingOccurrences(of: ")", with: "")
- .replacingOccurrences(of: "%", with: "")
- let parts = inner.split(whereSeparator: { $0 == "," || $0 == " " || $0 == "/" })
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- if parts.count >= 3 {
- guard let h = Double(parts[0]),
- let s = Double(parts[1]),
- let l = Double(parts[2]) else { return nil }
- let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
- let hNorm = (h.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 360.0
- let sNorm = max(0, min(100, s)) / 100.0
- let lNorm = max(0, min(100, l)) / 100.0
- return NSColor(hue: CGFloat(hNorm), saturation: CGFloat(sNorm), brightness: CGFloat(lNorm), alpha: CGFloat(max(0, min(1.0, a))))
- }
- }
-
- return nil
- }
-}
-
-// MARK: - Text case converter
-
-enum CaseConverter {
- enum CaseStyle: String, CaseIterable, Identifiable {
- case camelCase = "camelCase"
- case pascalCase = "PascalCase"
- case snakeCase = "snake_case"
- case kebabCase = "kebab-case"
- case constantCase = "CONSTANT_CASE"
- case uppercase = "UPPERCASE"
- case lowercase = "lowercase"
- case titleCase = "Title Case"
-
- var id: String { rawValue }
- }
-
- static func convert(_ text: String, to style: CaseStyle) -> String {
- let words = splitWords(text)
- guard !words.isEmpty else {
- switch style {
- case .uppercase: return text.uppercased()
- case .lowercase: return text.lowercased()
- default: return text
- }
- }
-
- switch style {
- case .camelCase:
- let first = words[0].lowercased()
- let rest = words.dropFirst().map { $0.capitalized }
- return ([first] + rest).joined()
-
- case .pascalCase:
- return words.map { $0.capitalized }.joined()
+// MARK: - Shared row context menu
- case .snakeCase:
- return words.map { $0.lowercased() }.joined(separator: "_")
-
- case .kebabCase:
- return words.map { $0.lowercased() }.joined(separator: "-")
-
- case .constantCase:
- return words.map { $0.uppercased() }.joined(separator: "_")
-
- case .uppercase:
- return text.uppercased()
-
- case .lowercase:
- return text.lowercased()
+struct EntryContextMenu: View {
+ @EnvironmentObject private var state: AppState
+ let entry: ClipboardEntry
- case .titleCase:
- return words.map { $0.capitalized }.joined(separator: " ")
+ var body: some View {
+ Button("Copy") { state.copy(entry) }
+ Button(tagsTitle) {
+ state.promptManageTags(entry)
}
- }
-
- private static func splitWords(_ text: String) -> [String] {
- var words: [String] = []
- var current = ""
-
- func flush() {
- if !current.isEmpty {
- words.append(current)
- current = ""
+ if entry.type == .text || entry.type == .shell {
+ Button(shortcutTitle) {
+ state.promptSetShortcut(entry)
}
}
-
- let chars = Array(text)
- for i in 0.. 0 && chars[i-1].isLowercase)
- let nextIsLower = (i + 1 < chars.count && chars[i+1].isLowercase && current.count > 1)
- if prevIsLower || nextIsLower {
- flush()
- }
- }
- current.append(ch)
- } else if ch.isNumber {
- let prevIsLetter = (i > 0 && chars[i-1].isLetter)
- if prevIsLetter {
- flush()
+ if entry.type == .text || entry.type == .shell,
+ let content = entry.content, content.count <= TextTransformer.maxTransformLength {
+ Menu("Copy As") {
+ TransformMenuContent(content: content) { transformed in
+ state.copyTransformedText(transformed)
}
- current.append(ch)
- } else {
- flush()
}
}
- flush()
- return words
- }
-}
-
-// MARK: - Text transformer (Base64 & URL encode/decode)
-
-enum TextTransformer {
- static let maxTransformLength = 10_000
-
- static func decodeBase64(_ text: String) -> String? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.count >= 4, trimmed.count <= maxTransformLength else { return nil }
- let pattern = "^[A-Za-z0-9+/]+={0,2}$"
- guard trimmed.range(of: pattern, options: .regularExpression) != nil else { return nil }
- guard let data = Data(base64Encoded: trimmed),
- let decoded = String(data: data, encoding: .utf8),
- !decoded.isEmpty,
- decoded != trimmed,
- decoded.allSatisfy({ !$0.isASCII || $0.isWhitespace || $0.isLetter || $0.isNumber || $0.isPunctuation || $0.isSymbol }) else {
- return nil
+ if entry.type == .image, let ocrText = entry.content, !ocrText.isEmpty {
+ Button("Copy Extracted Text") { state.copyTransformedText(ocrText) }
}
- return decoded
+ Button(entry.isFavorite ? "Remove from Favs" : "Add to Favs") { state.toggleFavorite(entry) }
+ Button(entry.isPinned ? "Unpin" : "Pin") { state.togglePin(entry) }
+ Divider()
+ Button("Delete", role: .destructive) { state.delete(entry) }
}
- static func encodeBase64(_ text: String) -> String {
- Data(text.utf8).base64EncodedString()
+ private var tagsTitle: String {
+ entry.tags.isEmpty
+ ? "Manage Tags…"
+ : "Manage Tags (\(entry.tags.map { "#\($0)" }.joined(separator: ", ")))…"
}
- static func decodeURL(_ text: String) -> String? {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.contains("%"), trimmed.count <= maxTransformLength else { return nil }
- guard let decoded = trimmed.removingPercentEncoding, decoded != trimmed else { return nil }
- return decoded
- }
-
- static func encodeURL(_ text: String) -> String {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text
+ private var shortcutTitle: String {
+ entry.shortcut == nil
+ ? "Set Snippet Shortcut…"
+ : "Edit Snippet Shortcut (\(entry.shortcut ?? ""))…"
}
}
-// MARK: - JWT Parser & Inspector
-
-struct JWTData {
- let header: [String: Any]
- let payload: [String: Any]
- let headerJSON: String
- let payloadJSON: String
- let algorithm: String
- let isExpired: Bool?
- let expirationDate: Date?
- let issuedAtDate: Date?
- let subject: String?
- let issuer: String?
-
- static func parse(_ text: String?) -> JWTData? {
- guard let text else { return nil }
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.count >= 20, trimmed.count <= 20_000 else { return nil }
- let parts = trimmed.components(separatedBy: ".")
- guard parts.count == 3 else { return nil }
-
- guard let headerObj = decodeBase64URLJSON(parts[0]),
- let payloadObj = decodeBase64URLJSON(parts[1]) else {
- return nil
- }
+// MARK: - Shared "Copy As" transform menu sections
- let alg = (headerObj["alg"] as? String) ?? "Unknown"
- let typ = (headerObj["typ"] as? String)?.uppercased()
- guard headerObj["alg"] != nil || typ == "JWT" else {
- return nil
- }
+/// Single source of truth for the transform actions offered on text content.
+/// Embedded by the row context menu and the preview panel's metadata menu.
+struct TransformMenuContent: View {
+ let content: String
+ let onCopy: (String) -> Void
- let headerData = (try? JSONSerialization.data(withJSONObject: headerObj, options: [.prettyPrinted, .sortedKeys])) ?? Data()
- let payloadData = (try? JSONSerialization.data(withJSONObject: payloadObj, options: [.prettyPrinted, .sortedKeys])) ?? Data()
-
- let headerStr = String(data: headerData, encoding: .utf8) ?? "{}"
- let payloadStr = String(data: payloadData, encoding: .utf8) ?? "{}"
-
- var expDate: Date? = nil
- var isExp: Bool? = nil
- if let expNum = payloadObj["exp"] as? Double {
- let date = Date(timeIntervalSince1970: expNum)
- expDate = date
- isExp = date < Date()
- } else if let expInt = payloadObj["exp"] as? Int64 {
- let date = Date(timeIntervalSince1970: Double(expInt))
- expDate = date
- isExp = date < Date()
- } else if let expInt = payloadObj["exp"] as? Int {
- let date = Date(timeIntervalSince1970: Double(expInt))
- expDate = date
- isExp = date < Date()
+ var body: some View {
+ if let epoch = EpochData.parse(content) {
+ Section("Timestamp") {
+ Button("Copy ISO 8601 Date") { onCopy(epoch.iso8601) }
+ Button("Copy Local Formatted Date") { onCopy(epoch.localFormatted) }
+ if epoch.unitDescription.contains("Seconds") {
+ Button("Copy as Milliseconds (\(epoch.unixMillis))") {
+ onCopy(String(epoch.unixMillis))
+ }
+ } else {
+ Button("Copy as Seconds (\(epoch.unixSeconds))") {
+ onCopy(String(epoch.unixSeconds))
+ }
+ }
+ }
}
-
- var iatDate: Date? = nil
- if let iatNum = payloadObj["iat"] as? Double {
- iatDate = Date(timeIntervalSince1970: iatNum)
- } else if let iatInt = payloadObj["iat"] as? Int64 {
- iatDate = Date(timeIntervalSince1970: Double(iatInt))
- } else if let iatInt = payloadObj["iat"] as? Int {
- iatDate = Date(timeIntervalSince1970: Double(iatInt))
+ if let jwt = JWTData.parse(content) {
+ Section("JWT Token") {
+ Button("Copy Payload JSON") { onCopy(jwt.payloadJSON) }
+ Button("Copy Header JSON") { onCopy(jwt.headerJSON) }
+ }
}
-
- return JWTData(
- header: headerObj,
- payload: payloadObj,
- headerJSON: headerStr,
- payloadJSON: payloadStr,
- algorithm: alg,
- isExpired: isExp,
- expirationDate: expDate,
- issuedAtDate: iatDate,
- subject: payloadObj["sub"] as? String,
- issuer: payloadObj["iss"] as? String
- )
- }
-
- private static func decodeBase64URLJSON(_ base64URL: String) -> [String: Any]? {
- var base64 = base64URL
- .replacingOccurrences(of: "-", with: "+")
- .replacingOccurrences(of: "_", with: "/")
- while base64.count % 4 != 0 {
- base64.append("=")
+ if content.count <= 1000 {
+ Section("Text Case") {
+ ForEach(CaseConverter.CaseStyle.allCases) { style in
+ Button(style.rawValue) {
+ onCopy(CaseConverter.convert(content, to: style))
+ }
+ }
+ }
}
- guard let data = Data(base64Encoded: base64),
- let json = try? JSONSerialization.jsonObject(with: data, options: []),
- let dict = json as? [String: Any] else {
- return nil
+ Section("Encode / Decode") {
+ Button("Base64 Encode") { onCopy(TextTransformer.encodeBase64(content)) }
+ if let decoded = TextTransformer.decodeBase64(content) {
+ Button("Base64 Decode") { onCopy(decoded) }
+ }
+ Button("URL Encode") { onCopy(TextTransformer.encodeURL(content)) }
+ if let decoded = TextTransformer.decodeURL(content) {
+ Button("URL Decode") { onCopy(decoded) }
+ }
}
- return dict
}
}
-// MARK: - Epoch & Timestamp Parser
-
-struct EpochData {
- let date: Date
- let unitDescription: String
- let localFormatted: String
- let iso8601: String
- let relativeFormatted: String
- let unixSeconds: Int64
- let unixMillis: Int64
-
- private static let localFormatter: DateFormatter = {
- let df = DateFormatter()
- df.dateStyle = .full
- df.timeStyle = .long
- return df
- }()
-
- private static let isoFormatter: ISO8601DateFormatter = {
- let f = ISO8601DateFormatter()
- f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return f
- }()
-
- private static let relativeFormatter: RelativeDateTimeFormatter = {
- let f = RelativeDateTimeFormatter()
- f.unitsStyle = .full
- return f
- }()
-
- static func parse(_ text: String?) -> EpochData? {
- guard let text else { return nil }
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty, trimmed.count >= 9, trimmed.count <= 22 else { return nil }
-
- // Must be purely digits (or digits followed by .0 or decimal fractions)
- let parts = trimmed.components(separatedBy: ".")
- guard parts.count <= 2, parts[0].allSatisfy(\.isNumber) else { return nil }
- if parts.count == 2 {
- guard parts[1].allSatisfy(\.isNumber) else { return nil }
- }
-
- guard let rawDouble = Double(trimmed) else { return nil }
+// MARK: - Tag pill
- let date: Date
- let unit: String
+struct TagPillView: View {
+ let tag: String
- // Seconds: 1_000_000_000 ... 2_500_000_000 (10 digits: 2001 to 2049)
- if rawDouble >= 1_000_000_000 && rawDouble <= 2_500_000_000 {
- date = Date(timeIntervalSince1970: rawDouble)
- unit = "Seconds (10-digit)"
- }
- // Milliseconds: 1_000_000_000_000 ... 2_500_000_000_000 (13 digits)
- else if rawDouble >= 1_000_000_000_000 && rawDouble <= 2_500_000_000_000 {
- date = Date(timeIntervalSince1970: rawDouble / 1000.0)
- unit = "Milliseconds (13-digit)"
- }
- // Microseconds: 1_000_000_000_000_000 ... 2_500_000_000_000_000 (16 digits)
- else if rawDouble >= 1_000_000_000_000_000 && rawDouble <= 2_500_000_000_000_000 {
- date = Date(timeIntervalSince1970: rawDouble / 1_000_000.0)
- unit = "Microseconds (16-digit)"
- }
- // Nanoseconds: 1_000_000_000_000_000_000 ... 2_500_000_000_000_000_000 (19 digits)
- else if rawDouble >= 1_000_000_000_000_000_000 && rawDouble <= 2_500_000_000_000_000_000 {
- date = Date(timeIntervalSince1970: rawDouble / 1_000_000_000.0)
- unit = "Nanoseconds (19-digit)"
- } else {
- return nil
- }
-
- let seconds = Int64(date.timeIntervalSince1970)
- let millis = Int64(date.timeIntervalSince1970 * 1000)
-
- return EpochData(
- date: date,
- unitDescription: unit,
- localFormatted: localFormatter.string(from: date),
- iso8601: isoFormatter.string(from: date),
- relativeFormatted: relativeFormatter.localizedString(for: date, relativeTo: Date()),
- unixSeconds: seconds,
- unixMillis: millis
- )
+ var body: some View {
+ Text("#\(tag)")
+ .font(.system(size: 10.5, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.blue)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1.5)
+ .background(
+ Capsule()
+ .fill(Color.blue.opacity(0.10))
+ .overlay(
+ Capsule()
+ .strokeBorder(Color.blue.opacity(0.20), lineWidth: 0.5)
+ )
+ )
}
}
// MARK: - Search match highlighter
enum SearchHighlighter {
- static func highlight(
- text: String,
- query: String,
- isRegex: Bool,
- highlightColor: Color = Color(red: 1.0, green: 0.88, blue: 0.15)
- ) -> AttributedString {
+ static func highlight(text: String, query: String, isRegex: Bool) -> AttributedString {
var attributed = AttributedString(text)
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return attributed }
@@ -620,7 +274,7 @@ enum SearchHighlighter {
for match in matches {
if let swiftRange = Range(match.range, in: text),
let attrRange = Range(swiftRange, in: attributed) {
- attributed[attrRange].backgroundColor = highlightColor
+ attributed[attrRange].backgroundColor = Self.highlightColor
attributed[attrRange].foregroundColor = .black
}
}
@@ -630,7 +284,7 @@ enum SearchHighlighter {
var searchRange = text.startIndex.. String {
- let interval = max(0, Date().timeIntervalSince(date))
- if interval < 60 { return "now" }
- let minutes = Int(interval / 60)
- if minutes < 60 { return "\(minutes)m ago" }
- let hours = minutes / 60
- if hours < 24 { return "\(hours)h ago" }
- let days = hours / 24
- if days < 30 { return "\(days)d ago" }
- let months = days / 30
- if months < 12 { return "\(months)mo ago" }
- let years = days / 365
- return "\(years)y ago"
- }
-}
-
-/// Translucent panel background.
-struct VisualEffectBackground: NSViewRepresentable {
- func makeNSView(context: Context) -> NSVisualEffectView {
- let view = NSVisualEffectView()
- view.material = .popover
- view.blendingMode = .behindWindow
- view.state = .active
- return view
- }
-
- func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
-}
diff --git a/Sources/ClapApp/SettingsView+Persistence.swift b/Sources/ClapApp/SettingsView+Persistence.swift
new file mode 100644
index 0000000..b243c6d
--- /dev/null
+++ b/Sources/ClapApp/SettingsView+Persistence.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+import AppKit
+import ClapCore
+
+// MARK: - Persistence & component health for SettingsView
+
+extension SettingsView {
+
+ func save(_ key: String, _ value: String) {
+ guard loaded else { return }
+ enqueueSave(key: key, value: value)
+ }
+
+ func saveMegabytes(_ key: String, megabytes: Int) {
+ guard loaded else { return }
+ let clamped = max(1, megabytes)
+ let bytes = ByteSize.parse("\(clamped)MB") ?? Int64(clamped) * 1_048_576
+ enqueueSave(key: key, value: String(bytes))
+ }
+
+ /// Chains writes per config key: each task waits for its predecessor so a
+ /// burst of rapid changes lands strictly in the order they were made.
+ /// SettingsView is a struct, so the task captures a value copy; @State
+ /// writes still reach SwiftUI's external storage.
+ private func enqueueSave(key: String, value: String) {
+ let previous = saveTasks[key]
+ saveTasks[key] = Task {
+ _ = await previous?.value
+ guard !Task.isCancelled else { return }
+ do {
+ try await self.store.setConfig(key, value: value)
+ IPC.post(.configChanged)
+ } catch {
+ self.reportSaveFailure(key)
+ }
+ }
+ }
+
+ /// A failed settings write leaves the UI and the store disagreeing;
+ /// surface it instead of silently dropping it.
+ private func reportSaveFailure(_ key: String) {
+ saveError = String(localized: "Couldn't save \(key). Check disk space/permissions and reopen Settings.")
+ Task {
+ try? await Task.sleep(nanoseconds: Timing.saveErrorResetNanos)
+ saveError = nil
+ }
+ }
+
+ // MARK: - Component health
+
+ private func refreshHealth() {
+ guard let health = healthProvider?() else { return }
+ hotKeyOK = health.hotKeyOK
+ snippetTapOK = health.snippetTapOK
+ }
+
+ var healthSection: some View {
+ Section("Health") {
+ HStack {
+ Label {
+ Text("Global hotkey")
+ if !hotKeyOK {
+ Text("— registration failed; another app may own this shortcut")
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ }
+ } icon: {
+ Image(systemName: hotKeyOK ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .foregroundStyle(hotKeyOK ? Color.green : Color.orange)
+ }
+ Spacer()
+ }
+ HStack {
+ Label {
+ Text("Snippet listener")
+ if !snippetTapOK {
+ Text("— needs Accessibility permission; expansion is off")
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ }
+ } icon: {
+ Image(systemName: snippetTapOK ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .foregroundStyle(snippetTapOK ? Color.green : Color.orange)
+ }
+ Spacer()
+ }
+ }
+ }
+}
diff --git a/Sources/ClapApp/SettingsView.swift b/Sources/ClapApp/SettingsView.swift
index 9067d58..075a9d2 100644
--- a/Sources/ClapApp/SettingsView.swift
+++ b/Sources/ClapApp/SettingsView.swift
@@ -6,39 +6,30 @@ import ClapCore
/// Owns the standard titled Settings window (opened from the gear button and
/// the menu bar item).
@MainActor
-final class SettingsWindowController: NSObject {
+final class SettingsWindowController: UtilityWindowController {
private let store: ClipboardStore
- private var window: NSWindow?
+ var healthProvider: (() -> (hotKeyOK: Bool, snippetTapOK: Bool))?
init(store: ClipboardStore) {
self.store = store
- super.init()
+ super.init(title: "clap Settings",
+ contentRect: NSRect(x: 0, y: 0, width: 500, height: 640),
+ styleMask: [.titled, .closable, .miniaturizable])
}
func show() {
- if window == nil {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 500, height: 640),
- styleMask: [.titled, .closable, .miniaturizable],
- backing: .buffered,
- defer: false
- )
- window.title = "clap Settings"
- window.isReleasedWhenClosed = false
- window.contentView = NSHostingView(rootView: SettingsView(store: store))
- window.center()
- self.window = window
- }
- NSApp.activate(ignoringOtherApps: true)
- window?.makeKeyAndOrderFront(nil)
+ show(rootView: SettingsView(store: store, healthProvider: healthProvider))
}
}
struct SettingsView: View {
let store: ClipboardStore
+ /// Supplies live component health when the window opens. Set by the app
+ /// delegate; nil in previews.
+ var healthProvider: (() -> (hotKeyOK: Bool, snippetTapOK: Bool))?
- @State private var loaded = false
+ @State var loaded = false
@State private var stats: StoreStats?
@State private var textMaxEntries = 100_000
@State private var textMaxMB = 50
@@ -51,8 +42,15 @@ struct SettingsView: View {
@State private var retentionDays = 0
@State private var hotkey = "cmd+shift+v"
@State private var launchAtLogin = false
+ /// Serializes settings writes per key: rapid stepper/toggle changes each
+ /// await the previous task for that key, so the store always converges to
+ /// the newest value regardless of actor scheduling order.
+ @State var saveTasks: [String: Task] = [:]
@State private var suppressLoginToggle = false
@State private var launchError: String?
+ @State var saveError: String?
+ @State var hotKeyOK = true
+ @State var snippetTapOK = true
@State private var paused = false
@State private var pasteOnCopy = true
@State private var snippetsEnabled = true
@@ -60,18 +58,30 @@ struct SettingsView: View {
@State private var newExclusion = ""
var body: some View {
- formWithLimitHandlers
- .onChange(of: shellEnabled) { _, value in save("shell.enabled", value ? "1" : "0") }
- .onChange(of: shellHistfile) { _, value in save("shell.histfile", value.trimmingCharacters(in: .whitespaces)) }
- .onChange(of: retentionDays) { _, value in save("retention.days", String(value)) }
- .onChange(of: hotkey) { _, value in save("ui.hotkey", value) }
- .onChange(of: paused) { _, value in save("monitoring.paused", value ? "1" : "0") }
+ ZStack(alignment: .top) {
+ AdaptivePanelBackground()
+ .ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ Color.clear
+ .frame(height: 0)
+
+ formWithLimitHandlers
+ .scrollContentBackground(.hidden)
+ .clipped()
+ }
+ }
+ .onChange(of: shellEnabled) { _, value in save(ConfigKey.shellEnabled, value ? "1" : "0") }
+ .onChange(of: shellHistfile) { _, value in save(ConfigKey.shellHistfile, value.trimmingCharacters(in: .whitespaces)) }
+ .onChange(of: retentionDays) { _, value in save(ConfigKey.retentionDays, String(value)) }
+ .onChange(of: hotkey) { _, value in save(ConfigKey.uiHotkey, value) }
+ .onChange(of: paused) { _, value in save(ConfigKey.monitoringPaused, value ? "1" : "0") }
.onChange(of: snippetsEnabled) { _, value in
- save("snippets.enabled", value ? "1" : "0")
+ save(ConfigKey.snippetsEnabled, value ? "1" : "0")
SnippetExpander.shared.setEnabled(value)
}
.onChange(of: pasteOnCopy) { _, value in
- save("paste.on_copy", value ? "1" : "0")
+ save(ConfigKey.pasteOnCopy, value ? "1" : "0")
if value && !Paster.isTrusted {
Paster.promptAccessibility()
}
@@ -89,19 +99,21 @@ struct SettingsView: View {
private var formWithLimitHandlers: some View {
settingsForm
- .onChange(of: textMaxEntries) { _, value in save("text.max_entries", String(max(1, value))) }
- .onChange(of: textMaxMB) { _, value in saveMegabytes("text.max_size", megabytes: value) }
- .onChange(of: imageMaxEntries) { _, value in save("image.max_entries", String(max(1, value))) }
- .onChange(of: imageMaxMB) { _, value in saveMegabytes("image.max_size", megabytes: value) }
- .onChange(of: shellMaxEntries) { _, value in save("shell.max_entries", String(max(1, value))) }
- .onChange(of: shellMaxMB) { _, value in saveMegabytes("shell.max_size", megabytes: value) }
+ .onChange(of: textMaxEntries) { _, value in save(ConfigKey.textMaxEntries, String(max(1, value))) }
+ .onChange(of: textMaxMB) { _, value in saveMegabytes(ConfigKey.textMaxSize, megabytes: value) }
+ .onChange(of: imageMaxEntries) { _, value in save(ConfigKey.imageMaxEntries, String(max(1, value))) }
+ .onChange(of: imageMaxMB) { _, value in saveMegabytes(ConfigKey.imageMaxSize, megabytes: value) }
+ .onChange(of: shellMaxEntries) { _, value in save(ConfigKey.shellMaxEntries, String(max(1, value))) }
+ .onChange(of: shellMaxMB) { _, value in saveMegabytes(ConfigKey.shellMaxSize, megabytes: value) }
}
private var settingsForm: some View {
Form {
Section {
HStack(spacing: 14) {
- if let appIcon = NSImage(contentsOfFile: Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/AppIcon.png").path) ?? NSApp.applicationIconImage {
+ let iconPath = Bundle.main.bundleURL
+ .appendingPathComponent("Contents/Resources/AppIcon.png").path
+ if let appIcon = NSImage(contentsOfFile: iconPath) ?? NSApp.applicationIconImage {
Image(nsImage: appIcon)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -110,14 +122,24 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Clap")
.font(.title2.weight(.bold))
- Text("Local-first clipboard & shell history manager · v0.1.1")
- .font(.caption)
+ Text("Local-first clipboard & shell history manager · v0.2.0")
+ .font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
+ healthSection
+
+ if let saveError {
+ Section {
+ Label(saveError, systemImage: "exclamationmark.triangle.fill")
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+ }
+
Section("Text limits") {
NumericInputRow(
title: "Max entries",
@@ -307,21 +329,21 @@ struct SettingsView: View {
private func load() async {
guard !loaded else { return }
- textMaxEntries = await configInt("text.max_entries", fallback: 100_000)
- textMaxMB = await configBytesAsMB("text.max_size", fallback: 50)
- imageMaxEntries = await configInt("image.max_entries", fallback: 500)
- imageMaxMB = await configBytesAsMB("image.max_size", fallback: 100)
- shellEnabled = await configString("shell.enabled") != "0"
- shellMaxEntries = await configInt("shell.max_entries", fallback: 50_000)
- shellMaxMB = await configBytesAsMB("shell.max_size", fallback: 10)
- shellHistfile = (await configString("shell.histfile")) ?? ""
- retentionDays = await configInt("retention.days", fallback: 0)
- hotkey = (await configString("ui.hotkey")) ?? "cmd+shift+v"
- paused = await configString("monitoring.paused") == "1"
- snippetsEnabled = await configString("snippets.enabled") != "0"
- pasteOnCopy = await configString("paste.on_copy") != "0"
- launchAtLogin = await configString("launch_at_login") == "1"
- if let raw = await configString("exclusions"),
+ textMaxEntries = await configInt(ConfigKey.textMaxEntries, fallback: 100_000)
+ textMaxMB = await configBytesAsMB(ConfigKey.textMaxSize, fallback: 50)
+ imageMaxEntries = await configInt(ConfigKey.imageMaxEntries, fallback: 500)
+ imageMaxMB = await configBytesAsMB(ConfigKey.imageMaxSize, fallback: 100)
+ shellEnabled = await configString(ConfigKey.shellEnabled) != "0"
+ shellMaxEntries = await configInt(ConfigKey.shellMaxEntries, fallback: 50_000)
+ shellMaxMB = await configBytesAsMB(ConfigKey.shellMaxSize, fallback: 10)
+ shellHistfile = (await configString(ConfigKey.shellHistfile)) ?? ""
+ retentionDays = await configInt(ConfigKey.retentionDays, fallback: 0)
+ hotkey = (await configString(ConfigKey.uiHotkey)) ?? HotKeyDefinition.defaultID
+ paused = await configString(ConfigKey.monitoringPaused) == "1"
+ snippetsEnabled = await configString(ConfigKey.snippetsEnabled) != "0"
+ pasteOnCopy = await configString(ConfigKey.pasteOnCopy) != "0"
+ launchAtLogin = await configString(ConfigKey.launchAtLogin) == "1"
+ if let raw = await configString(ConfigKey.exclusions),
let data = raw.data(using: .utf8),
let array = try? JSONDecoder().decode([String].self, from: data) {
exclusions = array
@@ -350,24 +372,6 @@ struct SettingsView: View {
return fallback
}
- private func save(_ key: String, _ value: String) {
- guard loaded else { return }
- Task {
- try? await store.setConfig(key, value: value)
- IPC.post(.configChanged)
- }
- }
-
- private func saveMegabytes(_ key: String, megabytes: Int) {
- guard loaded else { return }
- let clamped = max(1, megabytes)
- let bytes = ByteSize.parse("\(clamped)MB") ?? Int64(clamped) * 1_048_576
- Task {
- try? await store.setConfig(key, value: String(bytes))
- IPC.post(.configChanged)
- }
- }
-
// MARK: - Launch at login
private func updateLaunchAtLogin(_ enabled: Bool) {
@@ -379,7 +383,7 @@ struct SettingsView: View {
try SMAppService.mainApp.unregister()
}
launchError = nil
- save("launch_at_login", enabled ? "1" : "0")
+ save(ConfigKey.launchAtLogin, enabled ? "1" : "0")
} catch {
// Typical in dev/unbundled builds where SMAppService is unavailable.
launchError = "Launch at login is unavailable: \(error.localizedDescription)"
@@ -424,7 +428,7 @@ struct SettingsView: View {
private func saveExclusions() {
guard let data = try? JSONEncoder().encode(exclusions),
let json = String(data: data, encoding: .utf8) else { return }
- save("exclusions", json)
+ save(ConfigKey.exclusions, json)
}
}
@@ -432,12 +436,12 @@ struct SettingsView: View {
private struct NumericInputRow: View {
let title: String
- var pill: String? = nil
- var pillRatio: Double? = nil
+ var pill: String?
+ var pillRatio: Double?
@Binding var value: Int
let range: ClosedRange
let step: Int
- var suffix: String? = nil
+ var suffix: String?
@State private var text: String = ""
diff --git a/Sources/ClapApp/ShellHistoryMonitor.swift b/Sources/ClapApp/ShellHistoryMonitor.swift
index 4d3b1ea..ce4b546 100644
--- a/Sources/ClapApp/ShellHistoryMonitor.swift
+++ b/Sources/ClapApp/ShellHistoryMonitor.swift
@@ -15,7 +15,8 @@ import os
actor ShellHistoryMonitor {
private let store: ClipboardStore
- private let logger = Logger(subsystem: "com.spongycode.clap", category: "shell")
+ private let logger = Logger(subsystem: ClapIdentity.bundleID, category: "shell")
+ private static let pollIntervalNanos = Timing.shellHistoryPollNanos
private var pollTask: Task?
private var enabled = true
@@ -32,11 +33,15 @@ actor ShellHistoryMonitor {
guard pollTask == nil else { return }
await refreshConfig()
- // One-time auto import of existing shell history if not yet imported
- let alreadyImported = ((try? await store.config("shell.initial_imported")) ?? "0") == "1"
+ // One-time auto import of existing shell history if not yet imported.
+ // The marker is only set on success so a failed first import retries
+ // on the next launch.
+ let alreadyImported = ((try? await store.config(ConfigKey.shellInitialImported)) ?? "0") == "1"
if !alreadyImported, enabled, let url = fileURL {
- await autoImportInitialHistory(from: url)
- try? await store.setConfig("shell.initial_imported", value: "1")
+ let imported = await autoImportInitialHistory(from: url)
+ if imported {
+ try? await store.setConfig(ConfigKey.shellInitialImported, value: "1")
+ }
}
if let url = fileURL, let (ino, size) = Self.fileStat(url) {
@@ -46,20 +51,25 @@ actor ShellHistoryMonitor {
pollTask = Task(priority: .utility) { [weak self] in
while !Task.isCancelled {
await self?.poll()
- try? await Task.sleep(nanoseconds: 2_000_000_000)
+ try? await Task.sleep(nanoseconds: Self.pollIntervalNanos)
}
}
logger.info("shell history monitor started")
}
- private func autoImportInitialHistory(from url: URL) async {
- guard let data = try? Data(contentsOf: url) else { return }
+ private func autoImportInitialHistory(from url: URL) async -> Bool {
+ guard let data = try? Data(contentsOf: url) else { return false }
let parsed = ShellHistoryParser.parse(data)
- guard !parsed.isEmpty else { return }
+ guard !parsed.isEmpty else { return false }
let batch = parsed.map { (text: $0.text, executedAt: $0.executedAt) }
- if let result = try? await store.ingestShellBatch(batch, source: url.lastPathComponent) {
+ do {
+ let result = try await store.ingestShellBatch(batch, source: url.lastPathComponent)
logger.info("initial auto-import completed: \(result.imported) new, \(result.merged) merged")
IPC.post(.storeChanged)
+ return true
+ } catch {
+ logger.error("initial auto-import failed: \(error.localizedDescription, privacy: .public)")
+ return false
}
}
@@ -69,7 +79,7 @@ actor ShellHistoryMonitor {
}
func refreshConfig() async {
- enabled = ((try? await store.config("shell.enabled")) ?? "1") == "1"
+ enabled = ((try? await store.config(ConfigKey.shellEnabled)) ?? "1") == "1"
let configured = (try? await store.config("shell.histfile")) ?? ""
if !configured.isEmpty {
fileURL = URL(fileURLWithPath: (configured as NSString).expandingTildeInPath)
diff --git a/Sources/ClapApp/SlideoutController.swift b/Sources/ClapApp/SlideoutController.swift
new file mode 100644
index 0000000..bda2331
--- /dev/null
+++ b/Sources/ClapApp/SlideoutController.swift
@@ -0,0 +1,172 @@
+import AppKit
+import SwiftUI
+
+public enum SlideoutState: Equatable, Sendable {
+ case opening
+ case closing
+ case open
+ case closed
+
+ public var isAnimating: Bool {
+ switch self {
+ case .closed, .open: return false
+ case .opening, .closing: return true
+ }
+ }
+
+ public var isOpen: Bool {
+ switch self {
+ case .open, .opening: return true
+ case .closed, .closing: return false
+ }
+ }
+
+ public func animationDone() -> SlideoutState {
+ switch self {
+ case .open, .opening: return .open
+ case .closed, .closing: return .closed
+ }
+ }
+}
+
+public enum SlideoutPlacement: String, Equatable, Sendable {
+ case left
+ case right
+}
+
+@MainActor
+public final class SlideoutController: ObservableObject {
+ public static let animationDuration: Double = 0.28
+
+ public let minimumContentWidth: CGFloat = 460
+ public let minimumSlideoutWidth: CGFloat = 320
+
+ @Published public var contentWidth: CGFloat = 480
+ @Published public var slideoutWidth: CGFloat = 360
+ @Published public var placement: SlideoutPlacement = .right
+ @Published public var state: SlideoutState = .closed
+
+ public weak var window: NSWindow?
+
+ private var windowAnimationOrigin: CGPoint?
+ private var windowAnimationOriginBaseState: SlideoutState = .closed
+ private var autoOpenTask: Task?
+ public var autoOpenDelayMs: Int = 1000
+
+ public init() {}
+
+ public func startAutoOpen(delayMs: Int? = nil) {
+ cancelAutoOpen()
+ guard !state.isOpen else { return }
+
+ let delay = delayMs ?? autoOpenDelayMs
+ autoOpenTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000)
+ guard !Task.isCancelled else { return }
+ guard let self else { return }
+ if !self.state.isOpen {
+ self.openPreview(animated: true)
+ }
+ }
+ }
+
+ public func cancelAutoOpen() {
+ autoOpenTask?.cancel()
+ autoOpenTask = nil
+ }
+
+ public func computePlacement(window: NSWindow, for size: NSSize) -> SlideoutPlacement {
+ guard let screen = window.screen?.visibleFrame else { return placement }
+ let windowFrame = window.frame
+ if windowFrame.minX + size.width > screen.maxX {
+ return .left
+ } else {
+ return .right
+ }
+ }
+
+ public func openPreview(animated: Bool = true) {
+ guard state != .open, state != .opening else { return }
+ guard let window else { return }
+
+ let targetSize = NSSize(width: contentWidth + slideoutWidth, height: window.frame.height)
+ placement = computePlacement(window: window, for: targetSize)
+
+ if animated {
+ windowAnimationOrigin = window.frame.origin
+ windowAnimationOriginBaseState = state
+
+ withAnimation(.easeInOut(duration: Self.animationDuration)) {
+ state = .opening
+
+ var newOrigin = windowAnimationOrigin ?? window.frame.origin
+ if placement == .left {
+ newOrigin.x -= slideoutWidth
+ }
+
+ let targetFrame = NSRect(origin: newOrigin, size: targetSize)
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = Self.animationDuration
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ context.completionHandler = { [weak self] in
+ guard let self else { return }
+ if self.state == .opening {
+ self.state = .open
+ }
+ }
+ window.animator().setFrame(targetFrame, display: true)
+ }
+ }
+ } else {
+ var newOrigin = window.frame.origin
+ if placement == .left && state == .closed {
+ newOrigin.x -= slideoutWidth
+ }
+ state = .open
+ window.setFrame(NSRect(origin: newOrigin, size: targetSize), display: true)
+ }
+ }
+
+ public func closePreview(animated: Bool = true) {
+ guard state != .closed, state != .closing else { return }
+ guard let window else { return }
+
+ let targetSize = NSSize(width: contentWidth, height: window.frame.height)
+
+ if animated {
+ windowAnimationOrigin = window.frame.origin
+ windowAnimationOriginBaseState = state
+
+ withAnimation(.easeInOut(duration: Self.animationDuration)) {
+ state = .closing
+
+ var newOrigin = windowAnimationOrigin ?? window.frame.origin
+ if placement == .left {
+ newOrigin.x += slideoutWidth
+ }
+
+ let targetFrame = NSRect(origin: newOrigin, size: targetSize)
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = Self.animationDuration
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ context.completionHandler = { [weak self] in
+ guard let self else { return }
+ if self.state == .closing {
+ self.state = .closed
+ }
+ }
+ window.animator().setFrame(targetFrame, display: true)
+ }
+ }
+ } else {
+ var newOrigin = window.frame.origin
+ if placement == .left && state == .open {
+ newOrigin.x += slideoutWidth
+ }
+ state = .closed
+ window.setFrame(NSRect(origin: newOrigin, size: targetSize), display: true)
+ }
+ }
+}
diff --git a/Sources/ClapApp/SlideoutView.swift b/Sources/ClapApp/SlideoutView.swift
new file mode 100644
index 0000000..b3db0ac
--- /dev/null
+++ b/Sources/ClapApp/SlideoutView.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+import AppKit
+
+private struct ConditionalWidthModifier: ViewModifier {
+ var width: CGFloat
+ var condition: Bool
+
+ func body(content: Content) -> some View {
+ if condition {
+ content.frame(width: width)
+ } else {
+ content
+ }
+ }
+}
+
+extension View {
+ fileprivate func conditionalWidth(_ width: CGFloat, condition: Bool) -> some View {
+ self.modifier(ConditionalWidthModifier(width: width, condition: condition))
+ }
+}
+
+public struct SlideoutView: View {
+ @ObservedObject var controller: SlideoutController
+
+ @ViewBuilder var content: () -> Content
+ @ViewBuilder var slideout: () -> Slideout
+
+ public init(
+ controller: SlideoutController,
+ @ViewBuilder content: @escaping () -> Content,
+ @ViewBuilder slideout: @escaping () -> Slideout
+ ) {
+ self.controller = controller
+ self.content = content
+ self.slideout = slideout
+ }
+
+ @State private var dragStartContentWidth: CGFloat?
+ @State private var dragStartSlideoutWidth: CGFloat?
+ @State private var isDraggingDivider = false
+
+ private var leftToRight: Bool {
+ controller.placement == .right
+ }
+
+ @ViewBuilder
+ private func resizeDivider() -> some View {
+ Divider()
+ .overlay(Color.primary.opacity(AppAlpha.Stroke.hairline))
+ .padding(.horizontal, 6)
+ .background(Color.white.opacity(0.001))
+ .contentShape(Rectangle())
+ .onHover { inside in
+ if let window = controller.window {
+ window.isMovableByWindowBackground = !inside && !isDraggingDivider
+ }
+ if inside {
+ if #available(macOS 15.0, *) {
+ NSCursor.columnResize.push()
+ } else {
+ NSCursor.resizeLeftRight.push()
+ }
+ } else if !isDraggingDivider {
+ NSCursor.pop()
+ }
+ }
+ .gesture(
+ DragGesture(minimumDistance: 1)
+ .onChanged { value in
+ if dragStartContentWidth == nil {
+ isDraggingDivider = true
+ dragStartContentWidth = controller.contentWidth
+ dragStartSlideoutWidth = controller.slideoutWidth
+ if let window = controller.window {
+ window.isMovableByWindowBackground = false
+ }
+ }
+ guard let startContent = dragStartContentWidth,
+ let startSlideout = dragStartSlideoutWidth else { return }
+
+ let total = startContent + startSlideout
+ let delta = (leftToRight ? 1 : -1) * value.translation.width
+ let rawContent = (startContent + delta).rounded()
+
+ let minContent = controller.minimumContentWidth
+ let maxContent = max(minContent, total - controller.minimumSlideoutWidth)
+
+ let clampedContent = min(maxContent, max(minContent, rawContent)).rounded()
+ let clampedSlideout = max(controller.minimumSlideoutWidth, total - clampedContent).rounded()
+
+ controller.contentWidth = clampedContent
+ controller.slideoutWidth = clampedSlideout
+ }
+ .onEnded { _ in
+ isDraggingDivider = false
+ dragStartContentWidth = nil
+ dragStartSlideoutWidth = nil
+ NSCursor.pop()
+ if let window = controller.window {
+ window.isMovableByWindowBackground = true
+ }
+ }
+ )
+ .disabled(controller.state != .open)
+ .frame(maxWidth: 0)
+ .opacity(controller.state != .closed ? 1 : 0)
+ }
+
+ public var body: some View {
+ HStack(spacing: 0) {
+ // Main List Content Column
+ VStack(spacing: 0) {
+ content()
+ }
+ .environment(\.layoutDirection, .leftToRight)
+ .frame(
+ minWidth: controller.minimumContentWidth,
+ idealWidth: controller.contentWidth.rounded(),
+ alignment: .leading
+ )
+ .frame(width: controller.contentWidth.rounded())
+ .fixedSize(horizontal: controller.state.isAnimating, vertical: false)
+
+ // Draggable Divider between list and slideout preview
+ resizeDivider()
+
+ // Slideout Preview Column
+ VStack(spacing: 0) {
+ slideout()
+ .frame(
+ minWidth: controller.minimumSlideoutWidth,
+ idealWidth: controller.slideoutWidth.rounded(),
+ maxWidth: controller.slideoutWidth.rounded(),
+ alignment: .leading
+ )
+ .conditionalWidth(
+ controller.slideoutWidth.rounded(),
+ condition: controller.state.isAnimating
+ )
+ .transition(.identity)
+ }
+ .environment(\.layoutDirection, .leftToRight)
+ .fixedSize(horizontal: controller.state.isAnimating, vertical: false)
+ .frame(
+ minWidth: controller.state != .open ? 0 : nil,
+ maxWidth: controller.state == .closed ? 0 : nil
+ )
+ .clipped()
+ .allowsHitTesting(controller.state != .closed)
+ }
+ .environment(\.layoutDirection, leftToRight ? .leftToRight : .rightToLeft)
+ }
+}
diff --git a/Sources/ClapApp/SnippetEditorWindow.swift b/Sources/ClapApp/SnippetEditorWindow.swift
index e2b9267..58d3b73 100644
--- a/Sources/ClapApp/SnippetEditorWindow.swift
+++ b/Sources/ClapApp/SnippetEditorWindow.swift
@@ -4,16 +4,16 @@ import ClapCore
/// Manages the dedicated titled Snippet Abbreviation window.
@MainActor
-final class SnippetWindowController: NSObject, NSWindowDelegate {
+final class SnippetWindowController: UtilityWindowController {
static let shared = SnippetWindowController()
- private var window: NSWindow?
- private var currentEntry: ClipboardEntry?
+ private init() {
+ super.init(title: "Snippet Abbreviation",
+ contentRect: NSRect(x: 0, y: 0, width: 440, height: 260))
+ }
func show(for entry: ClipboardEntry, state: AppState) {
- currentEntry = entry
-
- let view = SnippetEditorView(entry: entry, onSave: { [weak self] shortcut in
+ show(rootView: SnippetEditorView(entry: entry, onSave: { [weak self] shortcut in
state.setShortcut(shortcut, for: entry)
self?.close()
}, onRemove: { [weak self] in
@@ -21,29 +21,7 @@ final class SnippetWindowController: NSObject, NSWindowDelegate {
self?.close()
}, onCancel: { [weak self] in
self?.close()
- })
-
- if window == nil {
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 440, height: 260),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- window.title = "Snippet Abbreviation"
- window.isReleasedWhenClosed = false
- window.delegate = self
- self.window = window
- }
-
- window?.contentView = NSHostingView(rootView: view)
- window?.center()
- NSApp.activate(ignoringOtherApps: true)
- window?.makeKeyAndOrderFront(nil)
- }
-
- func close() {
- window?.close()
+ }))
}
}
@@ -94,10 +72,10 @@ struct SnippetEditorView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.primary.opacity(0.04))
+ .fill(Color.primary.opacity(AppAlpha.Fill.subtle))
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
- .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)
+ .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.hairline), lineWidth: 0.5)
)
)
}
@@ -129,10 +107,9 @@ struct SnippetEditorView: View {
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
- .background(Color(nsColor: .windowBackgroundColor))
}
.frame(width: 440, height: 260)
- .background(Color(nsColor: .windowBackgroundColor))
+ .background(AdaptivePanelBackground().ignoresSafeArea())
.onAppear {
text = entry.shortcut ?? ""
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
diff --git a/Sources/ClapApp/SnippetExpander.swift b/Sources/ClapApp/SnippetExpander.swift
index aac6576..c79172f 100644
--- a/Sources/ClapApp/SnippetExpander.swift
+++ b/Sources/ClapApp/SnippetExpander.swift
@@ -21,6 +21,10 @@ public final class SnippetExpander: @unchecked Sendable {
private var isEnabled = true
private var retryTimer: Timer?
+ /// True when the HID tap is installed (or AX permission is still being
+ /// retried). False means expansion cannot work this session.
+ public var isHealthy: Bool { eventTap != nil || retryTimer != nil }
+
private init() {}
/// Updates the active shortcuts mapping.
@@ -59,7 +63,7 @@ public final class SnippetExpander: @unchecked Sendable {
}
let mask = (1 << CGEventType.keyDown.rawValue)
- let callback: CGEventTapCallBack = { proxy, type, event, refcon in
+ let callback: CGEventTapCallBack = { _, type, event, refcon in
guard let refcon else { return Unmanaged.passUnretained(event) }
let expander = Unmanaged.fromOpaque(refcon).takeUnretainedValue()
@@ -74,7 +78,9 @@ public final class SnippetExpander: @unchecked Sendable {
return Unmanaged.passUnretained(event)
}
- let selfPtr = Unmanaged.passUnretained(self).toOpaque()
+ // Retained for the tap's lifetime (released in stop()) so the
+ // callback's refcon can never dangle.
+ let selfPtr = Unmanaged.passRetained(self).toOpaque()
guard let tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
@@ -83,6 +89,7 @@ public final class SnippetExpander: @unchecked Sendable {
callback: callback,
userInfo: selfPtr
) else {
+ Unmanaged.passUnretained(self).release()
logger.warning("Could not create cghidEventTap for snippet expansion")
return
}
@@ -106,6 +113,9 @@ public final class SnippetExpander: @unchecked Sendable {
}
eventTap = nil
runLoopSource = nil
+ // Balances the passRetained in start(); the singleton lives for
+ // the process lifetime, so this runs at teardown.
+ Unmanaged.passUnretained(self).release()
logger.info("SnippetExpander stopped")
}
}
diff --git a/Sources/ClapApp/TagEditorWindow.swift b/Sources/ClapApp/TagEditorWindow.swift
new file mode 100644
index 0000000..1e93dec
--- /dev/null
+++ b/Sources/ClapApp/TagEditorWindow.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+import AppKit
+import ClapCore
+
+/// Manages the dedicated titled window for managing tags on an entry.
+@MainActor
+final class TagWindowController: UtilityWindowController {
+ static let shared = TagWindowController()
+
+ private init() {
+ super.init(title: "Manage Tags & Pinboards",
+ contentRect: NSRect(x: 0, y: 0, width: 440, height: 320))
+ }
+
+ func show(for entry: ClipboardEntry, state: AppState) {
+ let allTags = state.availableTags.map(\.tag)
+ show(rootView: TagEditorView(
+ entry: entry,
+ suggestedTags: allTags,
+ onSave: { [weak self] newTags in
+ state.setTags(newTags, for: entry)
+ self?.close()
+ },
+ onCancel: { [weak self] in
+ self?.close()
+ }
+ ))
+ }
+}
+
+struct TagEditorView: View {
+ let entry: ClipboardEntry
+ let suggestedTags: [String]
+ let onSave: ([String]) -> Void
+ let onCancel: () -> Void
+
+ @State private var tags: [String] = []
+ @State private var newTagText: String = ""
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Assign tags to organize this entry into custom pinboards:")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ // Add Tag Field
+ HStack(spacing: 8) {
+ TextField("Add a tag (e.g. work, code, sql, prompt)", text: $newTagText)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 13))
+ .focused($isFocused)
+ .onSubmit {
+ addNewTag()
+ }
+
+ Button("Add") {
+ addNewTag()
+ }
+ .disabled(newTagText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ // Current Active Tags
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Active Tags:")
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ if tags.isEmpty {
+ Text("No tags assigned yet.")
+ .font(.system(size: 12))
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 4)
+ } else {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ ForEach(tags, id: \.self) { tag in
+ HStack(spacing: 4) {
+ Text("#\(tag)")
+ .font(.system(size: 11.5, weight: .semibold, design: .monospaced))
+ Button {
+ tags.removeAll { $0 == tag }
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3.5)
+ .background(
+ Capsule()
+ .fill(Color.accentColor.opacity(0.15))
+ .overlay(
+ Capsule()
+ .strokeBorder(Color.accentColor.opacity(0.3), lineWidth: 0.5)
+ )
+ )
+ }
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+
+ // Existing Suggestions
+ let suggestions = suggestedTags.filter { !tags.contains($0) }
+ if !suggestions.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Existing Pinboards:")
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ ForEach(suggestions, id: \.self) { suggestion in
+ Button {
+ tags.append(suggestion)
+ } label: {
+ HStack(spacing: 3) {
+ Image(systemName: "plus")
+ .font(.system(size: 9, weight: .bold))
+ Text("#\(suggestion)")
+ .font(.system(size: 11.5, weight: .medium, design: .monospaced))
+ }
+ .padding(.horizontal, 7)
+ .padding(.vertical, 3)
+ .background(
+ Capsule()
+ .fill(Color.primary.opacity(AppAlpha.Fill.soft))
+ .overlay(
+ Capsule()
+ .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder),
+ lineWidth: 0.5)
+ )
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+ }
+ .padding(18)
+
+ Divider()
+
+ // Action Buttons Bar
+ HStack {
+ if !tags.isEmpty {
+ Button("Clear Tags", role: .destructive) {
+ tags.removeAll()
+ }
+ }
+ Spacer()
+ Button("Cancel") {
+ onCancel()
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Button("Save") {
+ onSave(tags)
+ }
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 12)
+ }
+ .frame(width: 440)
+ .background(AdaptivePanelBackground().ignoresSafeArea())
+ .onAppear {
+ tags = entry.tags
+ isFocused = true
+ }
+ }
+
+ private func addNewTag() {
+ let cleaned = newTagText.trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "#"))
+ .lowercased()
+ guard !cleaned.isEmpty else { return }
+ if !tags.contains(cleaned) {
+ tags.append(cleaned)
+ }
+ newTagText = ""
+ }
+}
diff --git a/Sources/ClapApp/UtilityWindow.swift b/Sources/ClapApp/UtilityWindow.swift
new file mode 100644
index 0000000..5df04d8
--- /dev/null
+++ b/Sources/ClapApp/UtilityWindow.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+import AppKit
+
+/// Shared plumbing for the app's secondary titled windows (Settings, Snippet
+/// Abbreviation, Manage Tags): lazy window creation, activation, key ordering.
+@MainActor
+class UtilityWindowController: NSObject, NSWindowDelegate {
+
+ private var window: NSWindow?
+ private let title: String
+ private let contentRect: NSRect
+ private let styleMask: NSWindow.StyleMask
+
+ init(title: String, contentRect: NSRect, styleMask: NSWindow.StyleMask = [.titled, .closable]) {
+ self.title = title
+ self.contentRect = contentRect
+ self.styleMask = styleMask
+ super.init()
+ }
+
+ func show(rootView: some View) {
+ if window == nil {
+ let window = NSWindow(
+ contentRect: contentRect,
+ styleMask: styleMask,
+ backing: .buffered,
+ defer: false
+ )
+ window.title = title
+ window.isReleasedWhenClosed = false
+ window.delegate = self
+ // System Settings-style chrome: no visible titlebar strip; the
+ // traffic lights float directly on the Liquid Glass and the
+ // material runs edge to edge. Dragging works anywhere because
+ // of isMovableByWindowBackground.
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.titlebarAppearsTransparent = true
+ window.styleMask.insert(.fullSizeContentView)
+ window.isMovableByWindowBackground = true
+ window.center()
+ self.window = window
+ }
+ // Esc closes the window (hidden cancel-action button).
+ let chrome = rootView
+ .background {
+ Button("Close") { self.close() }
+ .keyboardShortcut(.cancelAction)
+ .opacity(0)
+ .accessibilityHidden(true)
+ }
+ window?.contentView = NSHostingView(rootView: chrome)
+ window?.center()
+ NSApp.activate()
+ window?.makeKeyAndOrderFront(nil)
+ }
+
+ func close() {
+ window?.close()
+ }
+
+ nonisolated func windowWillClose(_ notification: Notification) {
+ Task { @MainActor [weak self] in
+ self?.window = nil
+ }
+ }
+}
diff --git a/Sources/ClapApp/ViewModel.swift b/Sources/ClapApp/ViewModel.swift
index 23f8259..f1e24a7 100644
--- a/Sources/ClapApp/ViewModel.swift
+++ b/Sources/ClapApp/ViewModel.swift
@@ -25,50 +25,76 @@ final class AppState: ObservableObject {
let store: ClipboardStore
let monitor: PasteboardMonitor
+ let slideout = SlideoutController()
/// Set by PanelController — closes the panel.
var onCloseRequest: (() -> Void)?
/// Set by AppDelegate — opens the Settings window.
var onOpenSettings: (() -> Void)?
- @Published var tab: Tab = .classic {
- didSet {
- guard oldValue != tab else { return }
- selectedID = nil
- reload()
- }
- }
- @Published var rawQuery: String = "" {
- didSet {
- guard !suppressSearchTrigger, oldValue != rawQuery else { return }
- scheduleSearch()
- }
- }
+ @Published private(set) var tab: Tab = .classic
+ /// Search text as typed. Mutate through `queryChanged(_:)` so the
+ /// debounced search fires; direct writes stay silent (panel reset).
+ @Published private(set) var rawQuery: String = ""
/// Regex mode (⌘R / the .* button): the whole query is a regex pattern.
/// Sticky for the app's lifetime, not persisted.
- @Published var regexMode = false {
- didSet {
- guard oldValue != regexMode else { return }
- if !trimmedQuery.isEmpty { reload() }
- }
- }
+ @Published private(set) var regexMode = false
@Published private(set) var pinned: [ClipboardEntry] = []
@Published private(set) var entries: [ClipboardEntry] = []
@Published var selectedID: Int64?
@Published private(set) var searchError: String?
+ /// Selected tag in Favorites / Pinboards tab (nil = All)
+ @Published private(set) var selectedTag: String?
+ /// Available tags across the store with their entry counts
+ @Published private(set) var availableTags: [(tag: String, count: Int)] = []
/// Incremented to move keyboard focus into the search field.
@Published var searchFocusToken = 0
+ /// Last failed store mutation, shown as a dismissable banner. Auto-clears.
+ @Published private(set) var transientError: String?
+
+ func dismissTransientError() {
+ transientError = nil
+ }
+
+ // MARK: - UI intents (explicit state transitions)
+
+ func selectTab(_ newTab: Tab) {
+ guard tab != newTab else { return }
+ tab = newTab
+ selectedID = nil
+ reload()
+ }
+
+ func selectTag(_ tag: String?) {
+ guard selectedTag != tag else { return }
+ selectedTag = tag
+ selectedID = nil
+ reload()
+ }
+
+ func setRegexMode(_ enabled: Bool) {
+ guard regexMode != enabled else { return }
+ regexMode = enabled
+ if !trimmedQuery.isEmpty { reload() }
+ }
+
+ /// Live search-text updates from the field; debounced reload.
+ func queryChanged(_ newValue: String) {
+ guard rawQuery != newValue else { return }
+ rawQuery = newValue
+ scheduleSearch()
+ }
static let pageSize = 100
- private var suppressSearchTrigger = false
private var generation = 0
private var fetchedCount = 0 // rows fetched from the store (pre-dedup)
private var reachedEnd = false
private var isLoadingMore = false
private var searchDebounceTask: Task?
- private let thumbnailCache = NSCache()
- private let logger = Logger(subsystem: "com.spongycode.clap", category: "ui")
+ private var transientErrorTask: Task?
+ let thumbnailCache = NSCache()
+ let logger = Logger(subsystem: ClapIdentity.bundleID, category: "ui")
init(store: ClipboardStore, monitor: PasteboardMonitor) {
self.store = store
@@ -76,6 +102,27 @@ final class AppState: ObservableObject {
thumbnailCache.countLimit = 300
}
+ /// Runs a store mutation, surfacing failures instead of swallowing them:
+ /// logs at fault level and shows a transient banner in the UI.
+ func perform(_ label: String, _ op: () async throws -> Void) async {
+ do {
+ try await op()
+ } catch {
+ logger.fault("\(label, privacy: .public) failed: \(String(describing: error), privacy: .public)")
+ showTransientError(String(localized: "\(label) failed"))
+ }
+ }
+
+ func showTransientError(_ message: String) {
+ transientError = message
+ transientErrorTask?.cancel()
+ transientErrorTask = Task { [weak self] in
+ try? await Task.sleep(nanoseconds: Timing.errorBannerResetNanos)
+ guard !Task.isCancelled else { return }
+ self?.transientError = nil
+ }
+ }
+
// MARK: - Derived state
var trimmedQuery: String { rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -90,13 +137,16 @@ final class AppState: ObservableObject {
// MARK: - Lifecycle
- /// Called right before the panel is shown: reset search, reload page one.
+ /// Called right before the panel is shown: reset search, reload page one,
+ /// and disarm hover selection until the pointer moves again.
func panelWillShow() {
searchDebounceTask?.cancel()
- suppressSearchTrigger = true
+ slideout.cancelAutoOpen()
+ slideout.closePreview(animated: false)
rawQuery = ""
- suppressSearchTrigger = false
selectedID = nil
+ pointerArmed = false
+ pendingPointerEntryID = nil
reload()
}
@@ -105,7 +155,7 @@ final class AppState: ObservableObject {
private func scheduleSearch() {
searchDebounceTask?.cancel()
searchDebounceTask = Task { [weak self] in
- try? await Task.sleep(nanoseconds: 150_000_000)
+ try? await Task.sleep(nanoseconds: Timing.searchDebounceNanos)
guard !Task.isCancelled else { return }
self?.reload()
}
@@ -124,15 +174,41 @@ final class AppState: ObservableObject {
}
// The tab constrains types unless the query already used `type:`.
if query.type == nil { query.types = tab.types }
- if tab == .favs { query.favoriteOnly = true }
+ if tab == .favs {
+ if let selectedTag {
+ query.tag = selectedTag
+ } else {
+ query.favoriteOnly = true
+ }
+ }
return query
}
+ /// The default (no-search) query for a tab, or nil when the tab needs
+ /// special handling (Classic's pinned section). Pure and internal so the
+ /// tab→query mapping is unit-testable.
+ static func defaultQuery(tab: Tab, tag: String?, offset: Int) -> SearchQuery? {
+ switch tab {
+ case .classic:
+ return SearchQuery(types: [.text, .image], limit: Self.pageSize, offset: offset)
+ case .media:
+ return SearchQuery(type: .image, limit: Self.pageSize, offset: offset)
+ case .shell:
+ return SearchQuery(type: .shell, limit: Self.pageSize, offset: offset)
+ case .favs:
+ if let tag {
+ return SearchQuery(tag: tag, limit: Self.pageSize, offset: offset)
+ }
+ return SearchQuery(favoriteOnly: true, limit: Self.pageSize, offset: offset)
+ }
+ }
+
/// Reloads page one for the current tab + query.
func reload() {
generation += 1
let gen = generation
let currentTab = tab
+ let currentTag = selectedTag
let query = buildQuery(offset: 0)
Task { @MainActor [weak self] in
@@ -142,17 +218,13 @@ final class AppState: ObservableObject {
let fetched: [ClipboardEntry]
if let query {
fetched = try await self.store.search(query)
+ } else if currentTab == .classic {
+ newPinned = try await self.store.search(SearchQuery(pinnedOnly: true, limit: 50))
+ fetched = try await self.store.search(
+ Self.defaultQuery(tab: currentTab, tag: currentTag, offset: 0)!)
} else {
- if currentTab == .classic {
- newPinned = try await self.store.search(SearchQuery(pinnedOnly: true, limit: 50))
- fetched = try await self.store.search(SearchQuery(types: [.text, .image], limit: Self.pageSize, offset: 0))
- } else if currentTab == .media {
- fetched = try await self.store.list(type: .image, limit: Self.pageSize, offset: 0)
- } else if currentTab == .shell {
- fetched = try await self.store.list(type: .shell, limit: Self.pageSize, offset: 0)
- } else { // .favs
- fetched = try await self.store.search(SearchQuery(favoriteOnly: true, limit: Self.pageSize, offset: 0))
- }
+ fetched = try await self.store.search(
+ Self.defaultQuery(tab: currentTab, tag: currentTag, offset: 0)!)
}
guard gen == self.generation else { return }
self.searchError = nil
@@ -170,6 +242,7 @@ final class AppState: ObservableObject {
self.selectedID = self.flatRows.first?.id
}
self.refreshSnippets()
+ self.refreshTags()
} catch {
guard gen == self.generation else { return }
if case ClapCoreError.invalidPattern = error {
@@ -190,6 +263,7 @@ final class AppState: ObservableObject {
isLoadingMore = true
let gen = generation
let currentTab = tab
+ let currentTag = selectedTag
let offset = fetchedCount
let query = buildQuery(offset: offset)
@@ -200,14 +274,9 @@ final class AppState: ObservableObject {
let fetched: [ClipboardEntry]
if let query {
fetched = try await self.store.search(query)
- } else if currentTab == .classic {
- fetched = try await self.store.search(SearchQuery(types: [.text, .image], limit: Self.pageSize, offset: offset))
- } else if currentTab == .media {
- fetched = try await self.store.list(type: .image, limit: Self.pageSize, offset: offset)
- } else if currentTab == .shell {
- fetched = try await self.store.list(type: .shell, limit: Self.pageSize, offset: offset)
- } else { // .favs
- fetched = try await self.store.search(SearchQuery(favoriteOnly: true, limit: Self.pageSize, offset: offset))
+ } else {
+ fetched = try await self.store.search(
+ Self.defaultQuery(tab: currentTab, tag: currentTag, offset: offset)!)
}
guard gen == self.generation else { return }
self.fetchedCount += fetched.count
@@ -228,9 +297,42 @@ final class AppState: ObservableObject {
/// a hovered row would yank the list around under the cursor.
private(set) var selectionCameFromPointer = false
+ /// Hover-selection gate: when the panel opens under a stationary cursor,
+ /// the row's tracking area fires immediately and would steal the
+ /// selection from the newest entry (breaking blind paste). Hover may not
+ /// change the selection until the pointer physically moves after show().
+ private(set) var pointerArmed = false
+
+ /// The row the pointer was over when the panel opened (captured from the
+ /// initial hover event) or most recently entered while disarmed. Applied
+ /// the moment the pointer moves, so a tiny movement selects the row
+ /// already under the cursor — no leave/re-enter needed.
+ private var pendingPointerEntryID: Int64?
+
+ func armPointer() {
+ guard !pointerArmed else { return }
+ pointerArmed = true
+ if let pending = pendingPointerEntryID {
+ selectionCameFromPointer = true
+ selectedID = pending
+ }
+ }
+
+ /// Row hover tracking. While disarmed the hovered row is only remembered;
+ /// once armed it becomes the selection immediately.
+ func hoverChanged(_ id: Int64, hovering: Bool) {
+ if hovering {
+ pendingPointerEntryID = id
+ guard pointerArmed else { return }
+ selectionCameFromPointer = true
+ selectedID = id
+ } else if pendingPointerEntryID == id {
+ pendingPointerEntryID = nil
+ }
+ }
+
func selectFromPointer(_ id: Int64) {
- selectionCameFromPointer = true
- selectedID = id
+ hoverChanged(id, hovering: true)
}
func moveSelection(_ delta: Int) {
@@ -252,7 +354,7 @@ final class AppState: ObservableObject {
guard let entry = selectedEntry else { return }
Task { @MainActor [weak self] in
guard let self else { return }
- _ = try? await self.store.setPinned(!entry.isPinned, id: entry.id)
+ await self.perform("Pin") { try await self.store.setPinned(!entry.isPinned, id: entry.id) }
IPC.post(.storeChanged)
self.reload()
}
@@ -262,7 +364,7 @@ final class AppState: ObservableObject {
guard let entry = selectedEntry else { return }
Task { @MainActor [weak self] in
guard let self else { return }
- _ = try? await self.store.setFavorite(!entry.isFavorite, id: entry.id)
+ await self.perform("Favorite") { try await self.store.setFavorite(!entry.isFavorite, id: entry.id) }
IPC.post(.storeChanged)
self.reload()
}
@@ -277,7 +379,7 @@ final class AppState: ObservableObject {
: (index > 0 ? rows[index - 1].id : nil)
Task { @MainActor [weak self] in
guard let self else { return }
- _ = try? await self.store.delete(id: entry.id)
+ await self.perform("Delete") { try await self.store.delete(id: entry.id) }
self.selectedID = nextID
IPC.post(.storeChanged)
self.reload()
@@ -306,7 +408,7 @@ final class AppState: ObservableObject {
func setShortcut(_ shortcut: String?, for entry: ClipboardEntry) {
Task { @MainActor [weak self] in
guard let self else { return }
- _ = try? await self.store.setShortcut(shortcut, id: entry.id)
+ await self.perform("Shortcut") { try await self.store.setShortcut(shortcut, id: entry.id) }
IPC.post(.storeChanged)
self.reload()
self.refreshSnippets()
@@ -321,105 +423,44 @@ final class AppState: ObservableObject {
}
}
- // MARK: - Copy to pasteboard
+ func refreshTags() {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.availableTags = (try? await self.store.allTags()) ?? []
+ }
+ }
- /// Writes the entry to NSPasteboard.general. The monitor is told about
- /// the expected self-inflicted change first so it only bumps recency
- /// instead of re-capturing.
- func copy(_ entry: ClipboardEntry) {
+ func promptManageTags(_ entry: ClipboardEntry) {
+ TagWindowController.shared.show(for: entry, state: self)
+ }
+
+ func addTag(_ tag: String, to entry: ClipboardEntry) {
Task { @MainActor [weak self] in
guard let self else { return }
- switch entry.type {
- case .text:
- await self.monitor.expectSelfChange(entryID: entry.id)
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(entry.content ?? "", forType: .string)
- await self.monitor.confirmSelfChange(changeCount: pasteboard.changeCount)
- case .shell:
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(entry.content ?? "", forType: .string)
- try? await self.store.touch(id: entry.id)
- case .image:
- // Load the full image data first: only tell the monitor once
- // we know the write will actually happen.
- guard let url = await self.store.imageFileURL(for: entry) else { return }
- let data = await Task.detached(priority: .userInitiated) {
- try? Data(contentsOf: url)
- }.value
- guard let data else {
- self.logger.error("copy failed: image file missing for entry \(entry.id, privacy: .public)")
- return
- }
- await self.monitor.expectSelfChange(entryID: entry.id)
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- switch entry.imageFormat?.lowercased() {
- case "png":
- pasteboard.setData(data, forType: .png)
- case "tiff", "tif":
- pasteboard.setData(data, forType: .tiff)
- case "jpeg", "jpg":
- pasteboard.setData(data, forType: NSPasteboard.PasteboardType("public.jpeg"))
- default:
- // Unknown format: convert through NSImage to TIFF.
- if let tiff = NSImage(data: data)?.tiffRepresentation {
- pasteboard.setData(tiff, forType: .tiff)
- } else {
- pasteboard.setData(data, forType: .tiff)
- }
- }
- await self.monitor.confirmSelfChange(changeCount: pasteboard.changeCount)
- }
+ await self.perform("Add tag") { try await self.store.addTag(tag, entryID: entry.id) }
IPC.post(.storeChanged)
- self.onCloseRequest?()
-
- // Maccy-style paste-on-select: the panel never activated clap, so
- // the app the user came from still has key focus. Small delay so
- // the panel is gone and the pasteboard write has settled before
- // the synthetic Cmd+V lands.
- let pasteEnabled = ((try? await self.store.config("paste.on_copy")) ?? "1") == "1"
- if pasteEnabled {
- try? await Task.sleep(nanoseconds: 100_000_000)
- Paster.pasteToFrontmostApp()
- }
+ self.reload()
+ self.refreshTags()
}
}
- /// Writes transformed text to clipboard, captures it as a new entry,
- /// closes the panel, and optionally pastes it to the frontmost app.
- func copyTransformedText(_ text: String) {
+ func removeTag(_ tag: String, from entry: ClipboardEntry) {
Task { @MainActor [weak self] in
guard let self else { return }
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(text, forType: .string)
- _ = try? await self.store.captureText(text, sourceApp: "clap")
+ await self.perform("Remove tag") { try await self.store.removeTag(tag, entryID: entry.id) }
IPC.post(.storeChanged)
self.reload()
- self.onCloseRequest?()
-
- let pasteEnabled = ((try? await self.store.config("paste.on_copy")) ?? "1") == "1"
- if pasteEnabled {
- try? await Task.sleep(nanoseconds: 100_000_000)
- Paster.pasteToFrontmostApp()
- }
+ self.refreshTags()
}
}
- // MARK: - Thumbnails
-
- /// Loads (and lazily generates) the thumbnail for an image entry,
- /// cached in a small NSCache.
- func thumbnail(for entry: ClipboardEntry) async -> NSImage? {
- let key = NSNumber(value: entry.id)
- if let cached = thumbnailCache.object(forKey: key) { return cached }
- guard let url = try? await store.thumbnailURL(for: entry) else { return nil }
- let image = await Task.detached(priority: .utility) {
- NSImage(contentsOf: url)
- }.value
- if let image { thumbnailCache.setObject(image, forKey: key) }
- return image
+ func setTags(_ tags: [String], for entry: ClipboardEntry) {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ await self.perform("Save tags") { try await self.store.setTags(tags, entryID: entry.id) }
+ IPC.post(.storeChanged)
+ self.reload()
+ self.refreshTags()
+ }
}
}
diff --git a/Sources/ClapApp/Workers.swift b/Sources/ClapApp/Workers.swift
index cc92878..0c5d72b 100644
--- a/Sources/ClapApp/Workers.swift
+++ b/Sources/ClapApp/Workers.swift
@@ -1,17 +1,22 @@
import Foundation
import ClapCore
+import os
/// Background maintenance: limits/retention every 5 minutes (and at launch),
/// vacuum every hour, one-time thumbnail warmup for the newest 50 images.
/// Everything runs in detached low-priority tasks, never on the main actor.
final class MaintenanceWorkers {
+ private static let logger = Logger(subsystem: ClapIdentity.bundleID, category: "workers")
+ private static let limitsIntervalNanos: UInt64 = 300 * 1_000_000_000
+ private static let vacuumIntervalNanos: UInt64 = 3_600 * 1_000_000_000
+
private var tasks: [Task] = []
func start(store: ClipboardStore) {
guard tasks.isEmpty else { return }
- // One-time thumbnail warmup (failures ignored).
+ // One-time thumbnail warmup (failures logged, non-fatal).
tasks.append(Task.detached(priority: .background) {
if let images = try? await store.list(type: .image, limit: 50, offset: 0) {
for entry in images {
@@ -24,20 +29,28 @@ final class MaintenanceWorkers {
// Limits + retention: immediately, then every 5 minutes.
tasks.append(Task.detached(priority: .background) {
while !Task.isCancelled {
- let evicted = (try? await store.enforceLimits()) ?? 0
- let expired = (try? await store.applyRetention()) ?? 0
- if evicted + expired > 0 {
- IPC.post(.storeChanged)
+ do {
+ let evicted = try await store.enforceLimits()
+ let expired = try await store.applyRetention()
+ if evicted + expired > 0 {
+ IPC.post(.storeChanged)
+ }
+ } catch {
+ Self.logger.error("maintenance pass failed: \(error.localizedDescription, privacy: .public)")
}
- try? await Task.sleep(nanoseconds: 300 * 1_000_000_000)
+ try? await Task.sleep(nanoseconds: Self.limitsIntervalNanos)
}
})
// Vacuum: every hour.
tasks.append(Task.detached(priority: .background) {
while !Task.isCancelled {
- try? await store.vacuumIfNeeded()
- try? await Task.sleep(nanoseconds: 3_600 * 1_000_000_000)
+ do {
+ try await store.vacuumIfNeeded()
+ } catch {
+ Self.logger.error("vacuum failed: \(error.localizedDescription, privacy: .public)")
+ }
+ try? await Task.sleep(nanoseconds: Self.vacuumIntervalNanos)
}
})
}
diff --git a/Sources/ClapCLI/Main.swift b/Sources/ClapCLI/Main.swift
index 2f16953..c5ef5e3 100644
--- a/Sources/ClapCLI/Main.swift
+++ b/Sources/ClapCLI/Main.swift
@@ -1,129 +1,8 @@
-import Foundation
-import ClapCore
+import ClapCLIKit
-/// clap — clipboard manager CLI entry point.
-///
-/// Hand-rolled argument parsing, no external dependencies.
-/// Global flags (`--data-dir `) are accepted anywhere on the line and
-/// extracted before command dispatch.
@main
struct ClapMain {
- static let version = "0.1.1"
-
static func main() async {
- var args = Array(CommandLine.arguments.dropFirst())
- let dataDir = extractDataDir(&args)
- let context = CLIContext(dataDir: dataDir)
-
- guard !args.isEmpty else {
- OpenCommand.run(context: context)
- return
- }
-
- let command = args.removeFirst()
- switch command {
- case "--help", "-h", "help":
- print(HelpText.overview)
- case "--version", "version":
- print("clap \(version)")
- case "list":
- await ListCommand.run(args, context: context)
- case "search":
- await SearchCommand.run(args, context: context)
- case "get":
- await GetCommand.run(args, context: context)
- case "copy":
- await CopyCommand.run(args, context: context)
- case "delete":
- await DeleteCommand.run(args, context: context)
- case "out":
- await DeleteCommand.runOutAlias(args, context: context)
- case "pin":
- await PinCommand.run(args, pinned: true, context: context)
- case "unpin":
- await PinCommand.run(args, pinned: false, context: context)
- case "clear":
- await ClearCommand.run(args, context: context)
- case "stats":
- await StatsCommand.run(args, context: context)
- case "config":
- await ConfigCommand.run(args, context: context)
- case "doctor":
- await DoctorCommand.run(args, context: context)
- case "import":
- await ImportCommand.run(args, context: context)
- case "pause":
- await PauseCommand.run(args, paused: true, context: context)
- case "resume":
- await PauseCommand.run(args, paused: false, context: context)
- case "_capture":
- // Hidden: seeds the store for testing/scripting. Not in help.
- await CaptureCommand.run(args, context: context)
- case "_maintain":
- // Hidden: runs eviction/retention/vacuum like the app's workers.
- await MaintainCommand.run(args, context: context)
- default:
- CLI.usageError("unknown command '\(command)'", usage: HelpText.overview)
- }
- }
-
- /// Removes every `--data-dir ` / `--data-dir=` occurrence from
- /// the argument list and returns the last one, expanded.
- private static func extractDataDir(_ args: inout [String]) -> URL? {
- var result: URL?
- var i = 0
- while i < args.count {
- let arg = args[i]
- if arg == "--data-dir" {
- guard i + 1 < args.count else {
- CLI.usageError("--data-dir requires a path", usage: HelpText.overview)
- }
- result = URL(fileURLWithPath: (args[i + 1] as NSString).expandingTildeInPath,
- isDirectory: true)
- args.removeSubrange(i...(i + 1))
- } else if arg.hasPrefix("--data-dir=") {
- let raw = String(arg.dropFirst("--data-dir=".count))
- guard !raw.isEmpty else {
- CLI.usageError("--data-dir requires a path", usage: HelpText.overview)
- }
- result = URL(fileURLWithPath: (raw as NSString).expandingTildeInPath,
- isDirectory: true)
- args.remove(at: i)
- } else {
- i += 1
- }
- }
- return result
+ await ClapCLI.main()
}
}
-
-enum HelpText {
- static let overview = """
- clap \(ClapMain.version) — native macOS clipboard manager CLI
-
- Usage:
- clap Open the clipboard UI (asks ClapApp)
- clap list [--images] [--shell] [--limit N] [--offset N] [--json]
- clap search [--regex ] [--type text|image|shell] [--limit N] [--offset N] [--json]
- clap get [--json]
- clap copy
- clap delete | --text | --regex
- clap out [ | ] Alias for clap delete
- clap pin / clap unpin
- clap clear [--force]
- clap stats [--json]
- clap config get [key]
- clap config set
- clap doctor
- clap import maccy|shell-history [--dry-run]
- clap pause / clap resume
-
- Global options:
- --data-dir Data directory (default: ~/Library/Application Support/clap,
- or the CLAP_DATA_DIR environment variable)
- --help, -h Help for clap or any subcommand
- --version Print version
-
- Exit codes: 0 ok, 1 not found / no match, 2 usage error.
- """
-}
diff --git a/Sources/ClapCLI/CLISupport.swift b/Sources/ClapCLIKit/CLISupport.swift
similarity index 88%
rename from Sources/ClapCLI/CLISupport.swift
rename to Sources/ClapCLIKit/CLISupport.swift
index 112ffdf..2527fdb 100644
--- a/Sources/ClapCLI/CLISupport.swift
+++ b/Sources/ClapCLIKit/CLISupport.swift
@@ -117,35 +117,38 @@ struct ArgParser {
return v
}
+ /// Validates a single numeric entry id (> 0) without exiting.
+ /// Returns nil for anything invalid.
+ static func validatedID(_ raw: String?) -> Int64? {
+ guard let raw, let id = Int64(raw), id > 0 else { return nil }
+ return id
+ }
+
/// Requires a single positional numeric id.
func requiredID(commandName: String) -> Int64 {
- guard positionals.count == 1, let id = Int64(positionals[0]), id > 0 else {
+ guard positionals.count == 1, let id = Self.validatedID(positionals.first) else {
CLI.usageError("\(commandName) requires a numeric entry id", usage: usage)
}
return id
}
}
-/// Distributed notifications (IPC with ClapApp). Names are the binding
-/// contract in ARCHITECTURE.md.
+/// Distributed notifications (IPC with ClapApp). Names come from ClapCore's
+/// IPCNotifications so both targets share one source of truth.
enum Notify {
- static let openUIName = "com.spongycode.clap.openUI"
- static let storeChangedName = "com.spongycode.clap.storeChanged"
- static let configChangedName = "com.spongycode.clap.configChanged"
-
private static func post(_ name: String) {
DistributedNotificationCenter.default().postNotificationName(
Notification.Name(name), object: nil, userInfo: nil, deliverImmediately: true)
}
- static func openUI() { post(openUIName) }
- static func storeChanged() { post(storeChangedName) }
- static func configChanged() { post(configChangedName) }
+ static func openUI() { post(IPCNotifications.openUI) }
+ static func storeChanged() { post(IPCNotifications.storeChanged) }
+ static func configChanged() { post(IPCNotifications.configChanged) }
}
/// Detection of the background app process.
enum AppProcess {
- static let bundleID = "com.spongycode.clap"
+ static let bundleID = ClapIdentity.bundleID
static func isRunning() -> Bool {
if runningViaWorkspace() { return true }
diff --git a/Sources/ClapCLIKit/ClapCLI.swift b/Sources/ClapCLIKit/ClapCLI.swift
new file mode 100644
index 0000000..617af3c
--- /dev/null
+++ b/Sources/ClapCLIKit/ClapCLI.swift
@@ -0,0 +1,122 @@
+import Foundation
+
+/// clap — clipboard manager CLI dispatcher.
+///
+/// Hand-rolled argument parsing, no external dependencies.
+/// Global flags (`--data-dir `) are accepted anywhere on the line and
+/// extracted before command dispatch.
+public enum ClapCLI {
+ public static let version = "0.2.0"
+
+ /// Command dispatch table. Hidden/scripting commands are marked in help
+ /// comments only.
+ private static let commands: [String: ([String], CLIContext) async -> Void] = [
+ "list": { await ListCommand.run($0, context: $1) },
+ "search": { await SearchCommand.run($0, context: $1) },
+ "get": { await GetCommand.run($0, context: $1) },
+ "copy": { await CopyCommand.run($0, context: $1) },
+ "delete": { await DeleteCommand.run($0, context: $1) },
+ "out": { await DeleteCommand.runOutAlias($0, context: $1) },
+ "pin": { await PinCommand.run($0, pinned: true, context: $1) },
+ "unpin": { await PinCommand.run($0, pinned: false, context: $1) },
+ "tag": { await TagCommand.run($0, context: $1) },
+ "tags": { await TagCommand.run(["list"] + $0, context: $1) },
+ "clear": { await ClearCommand.run($0, context: $1) },
+ "stats": { await StatsCommand.run($0, context: $1) },
+ "config": { await ConfigCommand.run($0, context: $1) },
+ "doctor": { await DoctorCommand.run($0, context: $1) },
+ "import": { await ImportCommand.run($0, context: $1) },
+ "pause": { await PauseCommand.run($0, paused: true, context: $1) },
+ "resume": { await PauseCommand.run($0, paused: false, context: $1) },
+ // Hidden: seeds the store for testing/scripting. Not in help.
+ "_capture": { await CaptureCommand.run($0, context: $1) },
+ // Hidden: runs eviction/retention/vacuum like the app's workers.
+ "_maintain": { await MaintainCommand.run($0, context: $1) }
+ ]
+
+ public static func main(arguments: [String] = Array(CommandLine.arguments.dropFirst())) async {
+ var args = arguments
+ let dataDir = extractDataDir(&args)
+ let context = CLIContext(dataDir: dataDir)
+
+ guard !args.isEmpty else {
+ OpenCommand.run(context: context)
+ return
+ }
+
+ let command = args.removeFirst()
+ switch command {
+ case "--help", "-h", "help":
+ print(HelpText.overview)
+ case "--version", "version":
+ print("clap \(version)")
+ default:
+ guard let handler = commands[command] else {
+ CLI.usageError("unknown command '\(command)'", usage: HelpText.overview)
+ }
+ await handler(args, context)
+ }
+ }
+
+ /// Removes every `--data-dir ` / `--data-dir=` occurrence from
+ /// the argument list and returns the last one, expanded.
+ private static func extractDataDir(_ args: inout [String]) -> URL? {
+ var result: URL?
+ var i = 0
+ while i < args.count {
+ let arg = args[i]
+ if arg == "--data-dir" {
+ guard i + 1 < args.count else {
+ CLI.usageError("--data-dir requires a path", usage: HelpText.overview)
+ }
+ result = URL(fileURLWithPath: (args[i + 1] as NSString).expandingTildeInPath,
+ isDirectory: true)
+ args.removeSubrange(i...(i + 1))
+ } else if arg.hasPrefix("--data-dir=") {
+ let raw = String(arg.dropFirst("--data-dir=".count))
+ guard !raw.isEmpty else {
+ CLI.usageError("--data-dir requires a path", usage: HelpText.overview)
+ }
+ result = URL(fileURLWithPath: (raw as NSString).expandingTildeInPath,
+ isDirectory: true)
+ args.remove(at: i)
+ } else {
+ i += 1
+ }
+ }
+ return result
+ }
+}
+
+enum HelpText {
+ static let overview = """
+ clap \(ClapCLI.version) — native macOS clipboard manager CLI
+
+ Usage:
+ clap Open the clipboard UI (asks ClapApp)
+ clap list [--images] [--shell] [--tag ] [--limit N] [--offset N] [--json]
+ clap search [--regex ] [--type text|image|shell] [--tag ] [--limit N] [--offset N] [--json]
+ clap get [--json]
+ clap copy
+ clap delete | --text | --regex
+ clap out [ | ] Alias for clap delete
+ clap pin / clap unpin
+ clap tag add / clap tag remove
+ clap tags / clap tag list [id]
+ clap clear [--force]
+ clap stats [--json]
+ clap config get [key]
+ clap config set
+ clap doctor
+ clap import maccy|shell-history [--dry-run]
+ clap pause / clap resume
+
+ Global options:
+ --data-dir Data directory (default: ~/Library/Application Support/clap,
+ or the CLAP_DATA_DIR environment variable)
+ --help, -h Help for clap or any subcommand
+ --version Print version
+
+ Exit codes: 0 ok, 1 not found / no match, 2 usage error.
+ """
+}
diff --git a/Sources/ClapCLI/Commands/CaptureCommand.swift b/Sources/ClapCLIKit/Commands/CaptureCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/CaptureCommand.swift
rename to Sources/ClapCLIKit/Commands/CaptureCommand.swift
diff --git a/Sources/ClapCLI/Commands/ClearCommand.swift b/Sources/ClapCLIKit/Commands/ClearCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/ClearCommand.swift
rename to Sources/ClapCLIKit/Commands/ClearCommand.swift
diff --git a/Sources/ClapCLI/Commands/ConfigCommand.swift b/Sources/ClapCLIKit/Commands/ConfigCommand.swift
similarity index 78%
rename from Sources/ClapCLI/Commands/ConfigCommand.swift
rename to Sources/ClapCLIKit/Commands/ConfigCommand.swift
index 51c6973..b6d0690 100644
--- a/Sources/ClapCLI/Commands/ConfigCommand.swift
+++ b/Sources/ClapCLIKit/Commands/ConfigCommand.swift
@@ -21,15 +21,24 @@ enum ConfigCommand {
launch_at_login true/false or 1/0 (default false)
paste.on_copy true/false or 1/0 (default true) — paste into the
active app when selecting an entry in the UI
+
+ App-managed keys (readable and settable; written by ClapApp):
+ ui.hotkey hotkey preset id, e.g. cmd+shift+v
+ ui.panel_frame persisted panel frame
+ snippets.enabled true/false or 1/0 (default true)
+ shell.initial_imported true/false or 1/0 — one-time backfill marker
"""
static let knownKeys: Set = [
- "text.max_entries", "text.max_size",
- "image.max_entries", "image.max_size",
- "shell.enabled", "shell.max_entries", "shell.max_size", "shell.histfile",
- "monitoring.paused", "exclusions",
- "retention.days", "launch_at_login",
- "paste.on_copy",
+ ConfigKey.textMaxEntries, ConfigKey.textMaxSize,
+ ConfigKey.imageMaxEntries, ConfigKey.imageMaxSize,
+ ConfigKey.shellEnabled, ConfigKey.shellMaxEntries, ConfigKey.shellMaxSize,
+ ConfigKey.shellHistfile,
+ ConfigKey.monitoringPaused, ConfigKey.exclusions,
+ ConfigKey.retentionDays, ConfigKey.launchAtLogin,
+ ConfigKey.pasteOnCopy,
+ ConfigKey.uiHotkey, ConfigKey.uiPanelFrame,
+ ConfigKey.snippetsEnabled, ConfigKey.shellInitialImported
]
static func run(_ args: [String], context: CLIContext) async {
@@ -102,31 +111,32 @@ enum ConfigCommand {
/// on anything invalid.
private static func validatedValue(key: String, rawValue: String) -> String {
switch key {
- case "text.max_entries", "image.max_entries", "shell.max_entries":
+ case ConfigKey.textMaxEntries, ConfigKey.imageMaxEntries, ConfigKey.shellMaxEntries:
guard let n = Int(rawValue), n > 0 else {
CLI.usageError("\(key) must be a positive integer", usage: usage)
}
return String(n)
- case "text.max_size", "image.max_size", "shell.max_size":
+ case ConfigKey.textMaxSize, ConfigKey.imageMaxSize, ConfigKey.shellMaxSize:
guard let bytes = ByteSize.parse(rawValue), bytes > 0 else {
CLI.usageError("\(key) must be a size like 52428800, 50MB or 1.5GB", usage: usage)
}
return String(bytes)
- case "monitoring.paused", "launch_at_login", "paste.on_copy", "shell.enabled":
+ case ConfigKey.monitoringPaused, ConfigKey.launchAtLogin, ConfigKey.pasteOnCopy,
+ ConfigKey.shellEnabled, ConfigKey.snippetsEnabled, ConfigKey.shellInitialImported:
switch rawValue.lowercased() {
case "true", "1": return "1"
case "false", "0": return "0"
default:
CLI.usageError("\(key) must be true/false or 1/0", usage: usage)
}
- case "shell.histfile":
+ case ConfigKey.shellHistfile:
return rawValue.trimmingCharacters(in: .whitespaces)
- case "retention.days":
+ case ConfigKey.retentionDays:
guard let n = Int(rawValue), n >= 0 else {
CLI.usageError("retention.days must be a non-negative integer", usage: usage)
}
return String(n)
- case "exclusions":
+ case ConfigKey.exclusions:
guard let data = rawValue.data(using: .utf8),
let parsed = try? JSONDecoder().decode([String].self, from: data) else {
CLI.usageError("exclusions must be a JSON array of strings, e.g. [\"com.example.app\"]",
@@ -136,7 +146,9 @@ enum ConfigCommand {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let encoded = (try? encoder.encode(parsed)) ?? Data("[]".utf8)
- return String(decoding: encoded, as: UTF8.self)
+ return String(data: encoded, encoding: .utf8) ?? "[]"
+ case ConfigKey.uiHotkey, ConfigKey.uiPanelFrame:
+ return rawValue
default:
CLI.usageError("unknown config key '\(key)'", usage: usage)
}
diff --git a/Sources/ClapCLI/Commands/CopyCommand.swift b/Sources/ClapCLIKit/Commands/CopyCommand.swift
similarity index 89%
rename from Sources/ClapCLI/Commands/CopyCommand.swift
rename to Sources/ClapCLIKit/Commands/CopyCommand.swift
index 72f991e..bb384fe 100644
--- a/Sources/ClapCLI/Commands/CopyCommand.swift
+++ b/Sources/ClapCLIKit/Commands/CopyCommand.swift
@@ -63,12 +63,9 @@ enum CopyCommand {
}
static func pasteboardType(for format: String) -> NSPasteboard.PasteboardType {
- switch format.lowercased() {
- case "png": return .png
- case "tiff", "tif": return .tiff
- case "jpeg", "jpg": return NSPasteboard.PasteboardType("public.jpeg")
- case "gif": return NSPasteboard.PasteboardType("com.compuserve.gif")
- default: return .tiff
+ if let uti = ImageFormats.uti(forFormat: format) {
+ return NSPasteboard.PasteboardType(uti)
}
+ return .tiff
}
}
diff --git a/Sources/ClapCLI/Commands/DeleteCommand.swift b/Sources/ClapCLIKit/Commands/DeleteCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/DeleteCommand.swift
rename to Sources/ClapCLIKit/Commands/DeleteCommand.swift
diff --git a/Sources/ClapCLI/Commands/DoctorCommand.swift b/Sources/ClapCLIKit/Commands/DoctorCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/DoctorCommand.swift
rename to Sources/ClapCLIKit/Commands/DoctorCommand.swift
diff --git a/Sources/ClapCLI/Commands/GetCommand.swift b/Sources/ClapCLIKit/Commands/GetCommand.swift
similarity index 80%
rename from Sources/ClapCLI/Commands/GetCommand.swift
rename to Sources/ClapCLIKit/Commands/GetCommand.swift
index ff9d7f1..a1029fd 100644
--- a/Sources/ClapCLI/Commands/GetCommand.swift
+++ b/Sources/ClapCLIKit/Commands/GetCommand.swift
@@ -23,14 +23,20 @@ enum GetCommand {
}
if parsed.has("--json") {
- print(OutputFormatter.encodeJSON(OutputFormatter.entryJSON(entry, dataDir: dataDir)))
+ do {
+ print(try OutputFormatter.encodeJSON(OutputFormatter.entryJSON(entry, dataDir: dataDir)))
+ } catch {
+ CLI.fail("JSON encoding failed: \(error.localizedDescription)")
+ }
return
}
- let imageAbsolutePath = entry.imagePath.map {
- dataDir.appendingPathComponent("images", isDirectory: true)
- .appendingPathComponent($0).path
- }
+ let imageAbsolutePath: String? = entry.type == .image
+ ? entry.imagePath.map {
+ dataDir.appendingPathComponent("images", isDirectory: true)
+ .appendingPathComponent($0).path
+ }
+ : nil
// Pipe-friendly: raw content only when stdout is not a TTY.
guard CLI.stdoutIsTTY else {
@@ -52,7 +58,7 @@ enum GetCommand {
("Use count", String(entry.useCount)),
("Size", ByteSize.format(entry.sizeBytes)),
("Hash", entry.contentHash),
- ("Source app", entry.sourceApp ?? "—"),
+ ("Source app", entry.sourceApp ?? "—")
] + (entry.type == .image
? [("Format", entry.imageFormat ?? "?"), ("File", imageAbsolutePath ?? "—")]
: [])
diff --git a/Sources/ClapCLI/Commands/ImportCommand.swift b/Sources/ClapCLIKit/Commands/ImportCommand.swift
similarity index 82%
rename from Sources/ClapCLI/Commands/ImportCommand.swift
rename to Sources/ClapCLIKit/Commands/ImportCommand.swift
index 713bdf1..1c1518b 100644
--- a/Sources/ClapCLI/Commands/ImportCommand.swift
+++ b/Sources/ClapCLIKit/Commands/ImportCommand.swift
@@ -139,47 +139,21 @@ enum ImportCommand {
var imageBytes: Int64 = 0
if dryRun {
- for item in items {
- switch item.payload {
- case .text: textCount += 1
- case .image(let data, _): imageCount += 1; imageBytes += Int64(data.count)
- case .none: skipped += 1
- }
- }
+ let counts = Self.countDryRun(items)
print("Dry run — nothing written.")
- printSummary(textCount: textCount, imageCount: imageCount, merged: 0,
- skipped: skipped, imageBytes: imageBytes)
+ printSummary(textCount: counts.text, imageCount: counts.image, merged: 0,
+ skipped: counts.skipped, imageBytes: counts.imageBytes)
return
}
await CLI.run {
let store = try context.makeStore()
- for item in items {
- switch item.payload {
- case .text(let text):
- if let result = try await store.importText(
- text, createdAt: item.createdAt, lastUsedAt: item.lastUsedAt,
- useCount: item.useCount, pinned: item.pinned, sourceApp: item.sourceApp) {
- textCount += 1
- if result.merged { merged += 1 }
- } else {
- skipped += 1
- }
- case .image(let data, let format):
- if let result = try await store.importImage(
- data: data, format: format, createdAt: item.createdAt,
- lastUsedAt: item.lastUsedAt, useCount: item.useCount,
- pinned: item.pinned, sourceApp: item.sourceApp) {
- imageCount += 1
- imageBytes += Int64(data.count)
- if result.merged { merged += 1 }
- } else {
- skipped += 1
- }
- case .none:
- skipped += 1
- }
- }
+ let counts = try await Self.importItems(items, store: store)
+ textCount = counts.text
+ imageCount = counts.image
+ merged = counts.merged
+ skipped = counts.skipped
+ imageBytes = counts.imageBytes
Notify.storeChanged()
printSummary(textCount: textCount, imageCount: imageCount, merged: merged,
@@ -190,12 +164,14 @@ enum ImportCommand {
let stats = try await store.stats()
if let maxRaw = try await store.config("image.max_size"),
let maxSize = Int64(maxRaw), stats.imageBytes > maxSize {
+ let suggested = ByteSize.format(((stats.imageBytes / (256 * 1024 * 1024)) + 1) * 256 * 1024 * 1024)
+ .replacingOccurrences(of: " ", with: "")
print("""
Note: image storage (\(ByteSize.format(stats.imageBytes))) now exceeds the \
\(ByteSize.format(maxSize)) limit; the oldest unpinned images will be \
evicted on the next maintenance pass. To keep everything, raise the limit:
- clap config set image.max_size \(ByteSize.format(((stats.imageBytes / (256 * 1024 * 1024)) + 1) * 256 * 1024 * 1024).replacingOccurrences(of: " ", with: ""))
+ clap config set image.max_size \(suggested)
""")
}
}
@@ -224,7 +200,7 @@ enum ImportCommand {
let candidates = [
home.appendingPathComponent(
"Library/Containers/org.p0deje.Maccy/Data/Library/Application Support/Maccy/Storage.sqlite"),
- home.appendingPathComponent("Library/Application Support/Maccy/Storage.sqlite"),
+ home.appendingPathComponent("Library/Application Support/Maccy/Storage.sqlite")
]
return candidates.first { fm.fileExists(atPath: $0.path) }
}
@@ -266,10 +242,10 @@ enum ImportCommand {
// Preference order per item.
private static let textTypes = [
"public.utf8-plain-text", "public.text",
- "public.utf16-external-plain-text", "public.utf16-plain-text",
+ "public.utf16-external-plain-text", "public.utf16-plain-text"
]
private static let imageTypes: [(uti: String, format: String)] = [
- ("public.png", "png"), ("public.jpeg", "jpeg"), ("public.tiff", "tiff"),
+ ("public.png", "png"), ("public.jpeg", "jpeg"), ("public.tiff", "tiff")
]
static func readMaccyItems(dbPath: String) throws -> [MaccyItem] {
@@ -358,3 +334,62 @@ enum ImportCommand {
return .none
}
}
+
+private struct MaccyImportCounts {
+ var text = 0
+ var image = 0
+ var merged = 0
+ var skipped = 0
+ var imageBytes: Int64 = 0
+}
+
+extension ImportCommand {
+ /// Counts what a dry-run import would do without touching the store.
+ private static func countDryRun(_ items: [MaccyItem]) -> MaccyImportCounts {
+ var counts = MaccyImportCounts()
+ for item in items {
+ switch item.payload {
+ case .text: counts.text += 1
+ case .image(let data, _):
+ counts.image += 1
+ counts.imageBytes += Int64(data.count)
+ case .none: counts.skipped += 1
+ }
+ }
+ return counts
+ }
+
+ /// Imports every item, merging duplicates, accumulating per-kind counts.
+ private static func importItems(
+ _ items: [MaccyItem], store: ClipboardStore
+ ) async throws -> MaccyImportCounts {
+ var counts = MaccyImportCounts()
+ for item in items {
+ switch item.payload {
+ case .text(let text):
+ if let result = try await store.importText(
+ text, createdAt: item.createdAt, lastUsedAt: item.lastUsedAt,
+ useCount: item.useCount, pinned: item.pinned, sourceApp: item.sourceApp) {
+ counts.text += 1
+ if result.merged { counts.merged += 1 }
+ } else {
+ counts.skipped += 1
+ }
+ case .image(let data, let format):
+ if let result = try await store.importImage(
+ data: data, format: format, createdAt: item.createdAt,
+ lastUsedAt: item.lastUsedAt, useCount: item.useCount,
+ pinned: item.pinned, sourceApp: item.sourceApp) {
+ counts.image += 1
+ counts.imageBytes += Int64(data.count)
+ if result.merged { counts.merged += 1 }
+ } else {
+ counts.skipped += 1
+ }
+ case .none:
+ counts.skipped += 1
+ }
+ }
+ return counts
+ }
+}
diff --git a/Sources/ClapCLI/Commands/ListCommand.swift b/Sources/ClapCLIKit/Commands/ListCommand.swift
similarity index 68%
rename from Sources/ClapCLI/Commands/ListCommand.swift
rename to Sources/ClapCLIKit/Commands/ListCommand.swift
index 2a76913..618a772 100644
--- a/Sources/ClapCLI/Commands/ListCommand.swift
+++ b/Sources/ClapCLIKit/Commands/ListCommand.swift
@@ -3,20 +3,21 @@ import ClapCore
enum ListCommand {
static let usage = """
- Usage: clap list [--images] [--shell] [--limit N] [--offset N] [--json]
+ Usage: clap list [--images] [--shell] [--tag ] [--limit N] [--offset N] [--json]
Lists clipboard and shell entries, most recently used first.
- --images Only image entries
- --shell Only shell command entries
- --limit N Max rows (default 20)
- --offset N Skip N rows
- --json JSON output
+ --images Only image entries
+ --shell Only shell command entries
+ --tag Filter by tag / pinboard
+ --limit N Max rows (default 20)
+ --offset N Skip N rows
+ --json JSON output
"""
static func run(_ args: [String], context: CLIContext) async {
let parsed = ArgParser.parse(args,
boolFlags: ["--images", "--shell", "--json"],
- valueFlags: ["--limit", "--offset"],
+ valueFlags: ["--tag", "--limit", "--offset"],
usage: usage)
guard parsed.positionals.isEmpty else {
CLI.usageError("unexpected argument '\(parsed.positionals[0])'", usage: usage)
@@ -27,10 +28,12 @@ enum ListCommand {
let limit = parsed.int("--limit", default: 20, min: 1)
let offset = parsed.int("--offset", default: 0, min: 0)
let type: EntryType? = parsed.has("--images") ? .image : (parsed.has("--shell") ? .shell : nil)
+ let tag = parsed.value("--tag")
let (entries, dataDir) = await CLI.run { () -> ([ClipboardEntry], URL) in
let store = try context.makeStore()
- let entries = try await store.list(type: type, limit: limit, offset: offset)
+ let query = SearchQuery(type: type, tag: tag, limit: limit, offset: offset)
+ let entries = try await store.search(query)
return (entries, store.dataDir)
}
@@ -40,7 +43,7 @@ enum ListCommand {
}
guard !entries.isEmpty else {
print("No entries.")
- return
+ exit(ExitCode.failure)
}
print(OutputFormatter.table(entries))
}
diff --git a/Sources/ClapCLI/Commands/MaintainCommand.swift b/Sources/ClapCLIKit/Commands/MaintainCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/MaintainCommand.swift
rename to Sources/ClapCLIKit/Commands/MaintainCommand.swift
diff --git a/Sources/ClapCLI/Commands/OpenCommand.swift b/Sources/ClapCLIKit/Commands/OpenCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/OpenCommand.swift
rename to Sources/ClapCLIKit/Commands/OpenCommand.swift
diff --git a/Sources/ClapCLI/Commands/PauseCommand.swift b/Sources/ClapCLIKit/Commands/PauseCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/PauseCommand.swift
rename to Sources/ClapCLIKit/Commands/PauseCommand.swift
diff --git a/Sources/ClapCLI/Commands/PinCommand.swift b/Sources/ClapCLIKit/Commands/PinCommand.swift
similarity index 100%
rename from Sources/ClapCLI/Commands/PinCommand.swift
rename to Sources/ClapCLIKit/Commands/PinCommand.swift
diff --git a/Sources/ClapCLI/Commands/SearchCommand.swift b/Sources/ClapCLIKit/Commands/SearchCommand.swift
similarity index 86%
rename from Sources/ClapCLI/Commands/SearchCommand.swift
rename to Sources/ClapCLIKit/Commands/SearchCommand.swift
index 5347090..f715899 100644
--- a/Sources/ClapCLI/Commands/SearchCommand.swift
+++ b/Sources/ClapCLIKit/Commands/SearchCommand.swift
@@ -3,13 +3,14 @@ import ClapCore
enum SearchCommand {
static let usage = """
- Usage: clap search [--regex ] [--type text|image|shell] [--limit N] [--offset N] [--json]
+ Usage: clap search [--regex ] [--type text|image|shell] [--tag ] [--limit N] [--offset N] [--json]
Full-text search over clipboard and shell history. Query syntax: bare terms
(prefix-matched, AND-combined), "quoted phrase", regex:,
- type:text|image|shell.
+ type:text|image|shell, tag:.
--regex Regex search (overrides the query)
--type text|image|shell Restrict entry type
+ --tag Filter by tag / pinboard
--limit N Max rows (default 20)
--offset N Skip N rows
--json JSON output
@@ -18,7 +19,7 @@ enum SearchCommand {
static func run(_ args: [String], context: CLIContext) async {
let parsed = ArgParser.parse(args,
boolFlags: ["--json"],
- valueFlags: ["--regex", "--type", "--limit", "--offset"],
+ valueFlags: ["--regex", "--type", "--tag", "--limit", "--offset"],
usage: usage)
let limit = parsed.int("--limit", default: 20, min: 1)
let offset = parsed.int("--offset", default: 0, min: 0)
@@ -30,14 +31,16 @@ enum SearchCommand {
}
typeFilter = type
}
+ let tagFilter = parsed.value("--tag")
let rawQuery = parsed.positionals.joined(separator: " ")
var query: SearchQuery
if let pattern = parsed.value("--regex") {
- query = SearchQuery(regex: pattern, type: typeFilter, limit: limit, offset: offset)
+ query = SearchQuery(regex: pattern, type: typeFilter, tag: tagFilter, limit: limit, offset: offset)
} else if !rawQuery.isEmpty {
query = SearchQuery.parse(rawQuery, limit: limit, offset: offset)
if let typeFilter { query.type = typeFilter } // CLI flag wins
+ if let tagFilter { query.tag = tagFilter } // CLI flag wins
} else {
CLI.usageError("search requires a query or --regex", usage: usage)
}
diff --git a/Sources/ClapCLI/Commands/StatsCommand.swift b/Sources/ClapCLIKit/Commands/StatsCommand.swift
similarity index 92%
rename from Sources/ClapCLI/Commands/StatsCommand.swift
rename to Sources/ClapCLIKit/Commands/StatsCommand.swift
index ed09438..c46a64e 100644
--- a/Sources/ClapCLI/Commands/StatsCommand.swift
+++ b/Sources/ClapCLIKit/Commands/StatsCommand.swift
@@ -49,7 +49,11 @@ enum StatsCommand {
duplicatesAvoidedToday: stats.duplicatesAvoidedToday,
oldestEntry: stats.oldestEntry.map { OutputFormatter.iso8601.string(from: $0) }
)
- print(OutputFormatter.encodeJSON(json))
+ do {
+ print(try OutputFormatter.encodeJSON(json))
+ } catch {
+ CLI.fail("JSON encoding failed: \(error.localizedDescription)")
+ }
return
}
@@ -66,7 +70,7 @@ enum StatsCommand {
("Total storage", ByteSize.format(totalBytes)),
("Clipboard events today", String(stats.eventsToday)),
("Duplicates avoided", String(stats.duplicatesAvoidedToday)),
- ("Oldest entry", oldest),
+ ("Oldest entry", oldest)
]
let labelWidth = rows.map { $0.0.count }.max() ?? 0
print("Clap Statistics")
diff --git a/Sources/ClapCLIKit/Commands/TagCommand.swift b/Sources/ClapCLIKit/Commands/TagCommand.swift
new file mode 100644
index 0000000..2869779
--- /dev/null
+++ b/Sources/ClapCLIKit/Commands/TagCommand.swift
@@ -0,0 +1,122 @@
+import Foundation
+import ClapCore
+
+enum TagCommand {
+ static let usage = """
+ Usage: clap tag [args]
+ clap tags
+
+ Manage tags and pinboards for clipboard entries.
+
+ Subcommands:
+ add Add a tag to an entry (e.g. 'clap tag add 42 work')
+ remove Remove a tag from an entry
+ set Set exact list of tags for an entry
+ list [id] List all tags, or tags for a specific entry
+ clap tags Shortcut to list all tags and their counts
+ """
+
+ static func run(_ args: [String], context: CLIContext) async {
+ guard !args.isEmpty else {
+ await listAllTags(context: context)
+ return
+ }
+
+ let subcommand = args[0].lowercased()
+ let rest = Array(args.dropFirst())
+
+ switch subcommand {
+ case "add":
+ guard rest.count >= 2 else {
+ CLI.usageError("clap tag add requires and ", usage: usage)
+ }
+ let id = validatedID(rest[0], action: "add")
+ let tag = rest[1]
+ await CLI.run {
+ let store = try context.makeStore()
+ let changed = try await store.addTag(tag, entryID: id)
+ if changed {
+ print("Added tag '#\(tag.trimmingCharacters(in: CharacterSet(charactersIn: "#")))' to entry \(id).")
+ } else {
+ print("Tag already present or invalid on entry \(id).")
+ }
+ }
+
+ case "remove", "rm":
+ guard rest.count >= 2 else {
+ CLI.usageError("clap tag remove requires and ", usage: usage)
+ }
+ let id = validatedID(rest[0], action: "remove")
+ let tag = rest[1]
+ await CLI.run {
+ let store = try context.makeStore()
+ let changed = try await store.removeTag(tag, entryID: id)
+ if changed {
+ print("Removed tag '#\(tag.trimmingCharacters(in: CharacterSet(charactersIn: "#")))' from entry \(id).")
+ } else {
+ print("Tag not found on entry \(id).")
+ }
+ }
+
+ case "set":
+ guard rest.count >= 2 else {
+ CLI.usageError("clap tag set requires and at least one ", usage: usage)
+ }
+ let id = validatedID(rest[0], action: "set")
+ let tags = Array(rest.dropFirst())
+ await CLI.run {
+ let store = try context.makeStore()
+ try await store.setTags(tags, entryID: id)
+ print("Updated tags for entry \(id): \(tags.map { "#\($0)" }.joined(separator: ", "))")
+ }
+
+ case "list", "ls":
+ if let first = rest.first {
+ let id = validatedID(first, action: "list")
+ await listTagsForEntry(id: id, context: context)
+ } else {
+ await listAllTags(context: context)
+ }
+
+ default:
+ CLI.usageError("unknown tag subcommand '\(subcommand)'", usage: usage)
+ }
+ }
+
+ /// Same validation contract as ArgParser.requiredID: positive integer.
+ private static func validatedID(_ raw: String, action: String) -> Int64 {
+ guard let id = Int64(raw), id > 0 else {
+ CLI.usageError("clap tag \(action) requires a numeric entry id", usage: usage)
+ }
+ return id
+ }
+
+ private static func listAllTags(context: CLIContext) async {
+ await CLI.run {
+ let store = try context.makeStore()
+ let all = try await store.allTags()
+ guard !all.isEmpty else {
+ print("No tags defined yet.")
+ return
+ }
+ print("TAG".padding(toLength: 20, withPad: " ", startingAt: 0) + "ENTRIES")
+ print(String(repeating: "─", count: 32))
+ for item in all {
+ let tagStr = "#\(item.tag)".padding(toLength: 20, withPad: " ", startingAt: 0)
+ print("\(tagStr)\(item.count)")
+ }
+ }
+ }
+
+ private static func listTagsForEntry(id: Int64, context: CLIContext) async {
+ await CLI.run {
+ let store = try context.makeStore()
+ let tags = try await store.tags(for: id)
+ guard !tags.isEmpty else {
+ print("No tags on entry \(id).")
+ return
+ }
+ print("Tags on entry \(id): " + tags.map { "#\($0)" }.joined(separator: ", "))
+ }
+ }
+}
diff --git a/Sources/ClapCLI/OutputFormatter.swift b/Sources/ClapCLIKit/OutputFormatter.swift
similarity index 73%
rename from Sources/ClapCLI/OutputFormatter.swift
rename to Sources/ClapCLIKit/OutputFormatter.swift
index b024efd..263bfd9 100644
--- a/Sources/ClapCLI/OutputFormatter.swift
+++ b/Sources/ClapCLIKit/OutputFormatter.swift
@@ -20,38 +20,13 @@ enum OutputFormatter {
}
static func previewText(_ content: String) -> String {
- var flat = ""
- flat.reserveCapacity(min(content.count, previewMax + 1))
- for character in content {
- if flat.count > previewMax { break }
- if character == "\n" || character == "\r" || character == "\t" {
- flat.append(" ")
- } else if character.unicodeScalars.contains(where: {
- CharacterSet.controlCharacters.contains($0)
- }) {
- continue
- } else {
- flat.append(character)
- }
- }
- if flat.count > previewMax {
- return String(flat.prefix(previewMax - 1)) + "…"
- }
- return flat
+ TextSummaries.singleLine(content, maxChars: previewMax)
}
// MARK: - Time
static func relativeTime(_ date: Date, now: Date = Date()) -> String {
- let seconds = Int(now.timeIntervalSince(date))
- switch seconds {
- case ..<5: return "just now"
- case ..<60: return "\(seconds)s ago"
- case ..<3600: return "\(seconds / 60)m ago"
- case ..<86_400: return "\(seconds / 3600)h ago"
- case ..<(86_400 * 30): return "\(seconds / 86_400)d ago"
- default: return dayFormatter.string(from: date)
- }
+ TextSummaries.relativeTime(date, now: now)
}
static let dayFormatter = makeFormatter("yyyy-MM-dd")
@@ -77,7 +52,7 @@ enum OutputFormatter {
entry.isPinned ? "*" : "",
entry.type.rawValue,
preview(entry),
- relativeTime(entry.lastUsedAt),
+ relativeTime(entry.lastUsedAt)
])
}
let columns = rows[0].count
@@ -135,15 +110,23 @@ enum OutputFormatter {
)
}
- /// Pretty-printed JSON with stable (sorted) key order.
- static func encodeJSON(_ value: T) -> String {
+ /// Pretty-printed JSON with stable (sorted) key order. Throws rather than
+ /// masking encoding bugs with a silent "{}".
+ static func encodeJSON(_ value: T) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
- guard let data = try? encoder.encode(value) else { return "{}" }
- return String(decoding: data, as: UTF8.self)
+ let data = try encoder.encode(value)
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw ClapCoreError.io("JSON encoding produced invalid UTF-8")
+ }
+ return json
}
static func printEntriesJSON(_ entries: [ClipboardEntry], dataDir: URL) {
- print(encodeJSON(entries.map { entryJSON($0, dataDir: dataDir) }))
+ do {
+ print(try encodeJSON(entries.map { entryJSON($0, dataDir: dataDir) }))
+ } catch {
+ CLI.fail("JSON encoding failed: \(error.localizedDescription)")
+ }
}
}
diff --git a/Sources/ClapCore/ClipboardStore+Capture.swift b/Sources/ClapCore/ClipboardStore+Capture.swift
new file mode 100644
index 0000000..206cda8
--- /dev/null
+++ b/Sources/ClapCore/ClipboardStore+Capture.swift
@@ -0,0 +1,257 @@
+import Foundation
+
+// MARK: - Capture, shell ingestion and import
+//
+// All inserts go through insertEntry; all duplicate touches through touchRow.
+// OCR runs OUTSIDE the write transaction: accurate-mode recognition can take
+// hundreds of milliseconds, and holding the IMMEDIATE lock that long would
+// stall the other process sharing the database.
+
+extension ClipboardStore {
+
+ @discardableResult
+ public func captureText(_ raw: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? {
+ let normalized = TextNormalizer.normalize(raw)
+ guard !normalized.isEmpty else { return nil }
+ // A single entry larger than the whole category budget must never be
+ // stored: byte eviction would otherwise delete every older unpinned
+ // entry chasing a cap this entry alone exceeds.
+ let sizeBytes = Int64(normalized.utf8.count)
+ if sizeBytes > (try configInt64(ConfigKey.textMaxSize)) { return nil }
+ let hash = ContentHasher.textHash(normalized)
+ let timestamp = clock().timeIntervalSince1970
+ let day = Self.dayKey(clock())
+
+ return try db.transaction {
+ try incrementCounter("events:\(day)")
+ // content equality guards against a (rare) 64-bit hash collision
+ // silently discarding unrelated text as a "duplicate".
+ if let existing = try firstEntry("type = 'text' AND content_hash = ? AND content = ?",
+ [.text(hash), .text(normalized)]) {
+ try touchRow(id: existing.id, at: timestamp)
+ try incrementCounter("dups:\(day)")
+ guard let updated = try firstEntry("id = ?", [.int(existing.id)]) else {
+ throw ClapCoreError.database(code: 0, message: "entry vanished during capture")
+ }
+ return (updated, true)
+ }
+ let id = try insertEntry(type: .text, content: normalized, imagePath: nil, imageFormat: nil,
+ hash: hash, createdAt: timestamp, lastUsedAt: timestamp,
+ sizeBytes: sizeBytes, pinned: false, useCount: 1, sourceApp: sourceApp)
+ guard let inserted = try firstEntry("id = ?", [.int(id)]) else {
+ throw ClapCoreError.database(code: 0, message: "insert did not produce a row")
+ }
+ return (inserted, false)
+ }
+ }
+
+ @discardableResult
+ public func captureImage(data: Data, format: String, sourceApp: String?) async throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? {
+ guard !data.isEmpty else { return nil }
+ if Int64(data.count) > (try configInt64(ConfigKey.imageMaxSize)) { return nil }
+ let hash = ContentHasher.imageHash(data)
+ let ext = format.lowercased()
+ let relativePath = "\(hash).\(ext)"
+ let timestamp = clock().timeIntervalSince1970
+ let day = Self.dayKey(clock())
+ let ocrText = await ocr.recognizeText(from: data)
+
+ return try db.transaction {
+ try incrementCounter("events:\(day)")
+ if let existing = try firstEntry("type = 'image' AND content_hash = ?", [.text(hash)]) {
+ // Duplicate image: touch only, never rewrite the file.
+ try touchRow(id: existing.id, at: timestamp)
+ try incrementCounter("dups:\(day)")
+ guard let updated = try firstEntry("id = ?", [.int(existing.id)]) else {
+ throw ClapCoreError.database(code: 0, message: "entry vanished during capture")
+ }
+ return (updated, true)
+ }
+ let id = try storeNewImage(data: data, relativePath: relativePath, imageFormat: ext,
+ ocrText: ocrText, hash: hash,
+ createdAt: timestamp, lastUsedAt: timestamp,
+ pinned: false, useCount: 1, sourceApp: sourceApp)
+ guard let inserted = try firstEntry("id = ?", [.int(id)]) else {
+ throw ClapCoreError.database(code: 0, message: "insert did not produce a row")
+ }
+ return (inserted, false)
+ }
+ }
+
+ /// Ingests one executed shell command (live watcher and backfill both use
+ /// this). Dedup merges: re-running a command bumps recency and use_count
+ /// instead of inserting a new row. Daily clipboard counters are NOT
+ /// touched — commands aren't clipboard events. Returns nil when empty or
+ /// oversize.
+ @discardableResult
+ public func ingestShell(_ command: String, executedAt: Date?,
+ source: String? = nil) throws -> (id: Int64, merged: Bool)? {
+ guard let prepared = prepareShell(command) else { return nil }
+ let when = executedAt ?? clock()
+ return try db.transaction {
+ try upsertShellInCurrentTransaction(prepared, when: when, source: source)
+ }
+ }
+
+ /// Ingests a batch of shell commands within a single database transaction.
+ @discardableResult
+ public func ingestShellBatch(_ commands: [(text: String, executedAt: Date?)],
+ source: String? = nil) throws -> (imported: Int, merged: Int) {
+ guard !commands.isEmpty else { return (0, 0) }
+ var imported = 0
+ var merged = 0
+ try db.transaction {
+ for item in commands {
+ guard let prepared = prepareShell(item.text) else { continue }
+ let when = item.executedAt ?? clock()
+ let (_, didMerge) = try upsertShellInCurrentTransaction(prepared, when: when, source: source)
+ if didMerge { merged += 1 } else { imported += 1 }
+ }
+ }
+ return (imported, merged)
+ }
+
+ private func prepareShell(_ command: String) -> (normalized: String, hash: String, sizeBytes: Int64)? {
+ let normalized = TextNormalizer.normalize(command)
+ guard !normalized.isEmpty else { return nil }
+ let sizeBytes = Int64(normalized.utf8.count)
+ guard sizeBytes <= maxShellSize else { return nil }
+ return (normalized, ContentHasher.textHash(normalized), sizeBytes)
+ }
+
+ private var maxShellSize: Int64 {
+ (try? configInt64(ConfigKey.shellMaxSize)) ?? 10_485_760
+ }
+
+ /// Shared merge-or-insert pipeline for both ingest entry points.
+ /// Must run inside a transaction started by the caller.
+ private func upsertShellInCurrentTransaction(_ prepared: (normalized: String, hash: String, sizeBytes: Int64),
+ when: Date, source: String?) throws -> (id: Int64, merged: Bool) {
+ let timestamp = when.timeIntervalSince1970
+ if let existing = try firstEntry("type = 'shell' AND content_hash = ? AND content = ?",
+ [.text(prepared.hash), .text(prepared.normalized)]) {
+ try db.run("""
+ UPDATE entries SET
+ created_at = MIN(created_at, ?),
+ last_used_at = MAX(last_used_at, ?),
+ use_count = use_count + 1
+ WHERE id = ?
+ """,
+ [.double(timestamp), .double(timestamp), .int(existing.id)])
+ return (existing.id, true)
+ }
+ let id = try insertEntry(type: .shell, content: prepared.normalized, imagePath: nil, imageFormat: nil,
+ hash: prepared.hash, createdAt: timestamp, lastUsedAt: timestamp,
+ sizeBytes: prepared.sizeBytes, pinned: false, useCount: 1, sourceApp: source)
+ return (id, false)
+ }
+
+ // MARK: - Import
+
+ /// Imports a text entry from another clipboard manager, preserving its
+ /// history metadata. Unlike capture, this does not bump daily counters
+ /// (imported rows are not today's clipboard events). Duplicates merge:
+ /// earliest created_at, latest last_used_at, summed use_count, pin wins.
+ /// Returns nil when the text is empty after normalization or oversize.
+ @discardableResult
+ public func importText(_ raw: String, createdAt: Date, lastUsedAt: Date,
+ useCount: Int, pinned: Bool, sourceApp: String?) async throws -> (id: Int64, merged: Bool)? {
+ let normalized = TextNormalizer.normalize(raw)
+ guard !normalized.isEmpty else { return nil }
+ let sizeBytes = Int64(normalized.utf8.count)
+ if sizeBytes > (try configInt64(ConfigKey.textMaxSize)) { return nil }
+ let hash = ContentHasher.textHash(normalized)
+
+ return try db.transaction {
+ if let existing = try firstEntry("type = 'text' AND content_hash = ? AND content = ?",
+ [.text(hash), .text(normalized)]) {
+ try mergeImported(into: existing.id, createdAt: createdAt,
+ lastUsedAt: lastUsedAt, useCount: useCount, pinned: pinned)
+ return (existing.id, true)
+ }
+ let id = try insertEntry(type: .text, content: normalized, imagePath: nil, imageFormat: nil,
+ hash: hash, createdAt: createdAt.timeIntervalSince1970,
+ lastUsedAt: lastUsedAt.timeIntervalSince1970,
+ sizeBytes: sizeBytes, pinned: pinned, useCount: useCount,
+ sourceApp: sourceApp)
+ return (id, false)
+ }
+ }
+
+ /// Image counterpart of `importText`. See its semantics.
+ @discardableResult
+ public func importImage(data: Data, format: String, createdAt: Date, lastUsedAt: Date,
+ useCount: Int, pinned: Bool, sourceApp: String?) async throws -> (id: Int64, merged: Bool)? {
+ guard !data.isEmpty else { return nil }
+ if Int64(data.count) > (try configInt64(ConfigKey.imageMaxSize)) { return nil }
+ let hash = ContentHasher.imageHash(data)
+ let ext = format.lowercased()
+ let relativePath = "\(hash).\(ext)"
+ let ocrText = await ocr.recognizeText(from: data)
+
+ return try db.transaction {
+ if let existing = try firstEntry("type = 'image' AND content_hash = ?", [.text(hash)]) {
+ try mergeImported(into: existing.id, createdAt: createdAt,
+ lastUsedAt: lastUsedAt, useCount: useCount, pinned: pinned)
+ return (existing.id, true)
+ }
+ let id = try storeNewImage(data: data, relativePath: relativePath, imageFormat: ext,
+ ocrText: ocrText, hash: hash,
+ createdAt: createdAt.timeIntervalSince1970,
+ lastUsedAt: lastUsedAt.timeIntervalSince1970,
+ pinned: pinned, useCount: useCount, sourceApp: sourceApp)
+ return (id, false)
+ }
+ }
+
+ /// Writes original bytes atomically, then inserts the row. If the insert
+ /// fails the row rolls back with the transaction and the file must not be
+ /// left orphaned on disk.
+ private func storeNewImage(data: Data, relativePath: String, imageFormat: String, ocrText: String?,
+ hash: String, createdAt: Double, lastUsedAt: Double,
+ pinned: Bool, useCount: Int, sourceApp: String?) throws -> Int64 {
+ let fileURL = imagesDirectory.appendingPathComponent(relativePath)
+ do {
+ try data.write(to: fileURL, options: .atomic)
+ try? FileManager.default.setAttributes(CoreConstants.ownerOnlyFileAttributes,
+ ofItemAtPath: fileURL.path)
+ } catch {
+ Self.logger.error("image file write failed: \(error.localizedDescription, privacy: .public)")
+ throw ClapCoreError.io("failed to write image file at \(fileURL.path)")
+ }
+ do {
+ return try insertEntry(type: .image, content: ocrText, imagePath: relativePath,
+ imageFormat: imageFormat, hash: hash, createdAt: createdAt,
+ lastUsedAt: lastUsedAt, sizeBytes: Int64(data.count),
+ pinned: pinned, useCount: useCount, sourceApp: sourceApp)
+ } catch {
+ do {
+ try FileManager.default.removeItem(at: fileURL)
+ } catch {
+ Self.logger.error("orphan image cleanup failed: \(error.localizedDescription, privacy: .public)")
+ }
+ throw error
+ }
+ }
+
+ /// Updates the extracted OCR text for an image entry.
+ public func updateOCRText(for entryID: Int64, ocrText: String) throws {
+ try db.run("UPDATE entries SET content = ? WHERE id = ? AND type = 'image'",
+ [.text(ocrText), .int(entryID)])
+ }
+
+ /// Must run inside a transaction started by the caller.
+ func mergeImported(into id: Int64, createdAt: Date, lastUsedAt: Date,
+ useCount: Int, pinned: Bool) throws {
+ try db.run("""
+ UPDATE entries SET
+ created_at = MIN(created_at, ?),
+ last_used_at = MAX(last_used_at, ?),
+ use_count = use_count + ?,
+ is_pinned = MAX(is_pinned, ?)
+ WHERE id = ?
+ """,
+ [.double(createdAt.timeIntervalSince1970), .double(lastUsedAt.timeIntervalSince1970),
+ .int(Int64(max(1, useCount))), .int(pinned ? 1 : 0), .int(id)])
+ }
+}
diff --git a/Sources/ClapCore/ClipboardStore+Diagnostics.swift b/Sources/ClapCore/ClipboardStore+Diagnostics.swift
new file mode 100644
index 0000000..ae5ad2a
--- /dev/null
+++ b/Sources/ClapCore/ClipboardStore+Diagnostics.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+// MARK: - Settings, stats, doctor diagnostics
+
+extension ClipboardStore {
+
+ static let configDefaults: [String: String] = [
+ ConfigKey.textMaxEntries: "100000",
+ ConfigKey.textMaxSize: "52428800",
+ ConfigKey.imageMaxEntries: "500",
+ ConfigKey.imageMaxSize: "104857600",
+ ConfigKey.monitoringPaused: "0",
+ ConfigKey.exclusions: "[]",
+ ConfigKey.retentionDays: "0",
+ ConfigKey.launchAtLogin: "0",
+ // Synthesize Cmd+V into the frontmost app after copying from the UI
+ // Requires Accessibility permission; falls back to
+ // copy-only when not granted.
+ ConfigKey.pasteOnCopy: "1",
+ // Shell history (zsh/bash) ingestion.
+ ConfigKey.shellEnabled: "1",
+ ConfigKey.shellMaxEntries: "50000",
+ ConfigKey.shellMaxSize: "10485760", // 10 MB
+ ConfigKey.shellHistfile: "" // empty = auto-detect
+ ]
+
+ /// Returns the stored value, falling back to the documented default when
+ /// the key is a known config key, else nil.
+ public func config(_ key: String) throws -> String? {
+ if let stored = try db.scalarText("SELECT value FROM config WHERE key = ?", [.text(key)]) {
+ return stored
+ }
+ return Self.configDefaults[key]
+ }
+
+ public func setConfig(_ key: String, value: String) throws {
+ try db.run("""
+ INSERT INTO config (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ """, [.text(key), .text(value)])
+ }
+
+ /// All config, with defaults merged in so every documented key appears.
+ /// Sorted by key.
+ public func allConfig() throws -> [(key: String, value: String)] {
+ var merged = Self.configDefaults
+ let stored = try db.query("SELECT key, value FROM config") {
+ (key: $0.text(0) ?? "", value: $0.text(1) ?? "")
+ }
+ for row in stored { merged[row.key] = row.value }
+ return merged.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
+ }
+
+ public func stats() throws -> StoreStats {
+ var textCount = 0, imageCount = 0, shellCount = 0
+ var textBytes: Int64 = 0, imageBytes: Int64 = 0, shellBytes: Int64 = 0
+ _ = try db.query("SELECT type, COUNT(*), COALESCE(SUM(size_bytes), 0) FROM entries GROUP BY type") { stmt in
+ switch stmt.text(0) {
+ case "text":
+ textCount = Int(stmt.int64(1)); textBytes = stmt.int64(2)
+ case "image":
+ imageCount = Int(stmt.int64(1)); imageBytes = stmt.int64(2)
+ case "shell":
+ shellCount = Int(stmt.int64(1)); shellBytes = stmt.int64(2)
+ default:
+ break
+ }
+ }
+ let pinned = Int(try db.scalarInt64("SELECT COUNT(*) FROM entries WHERE is_pinned = 1") ?? 0)
+ let day = Self.dayKey(clock())
+ let events = Int(try db.scalarInt64("SELECT value FROM stats_counters WHERE key = ?",
+ [.text("events:\(day)")]) ?? 0)
+ let dups = Int(try db.scalarInt64("SELECT value FROM stats_counters WHERE key = ?",
+ [.text("dups:\(day)")]) ?? 0)
+ let oldest = try db.scalarDouble("SELECT MIN(created_at) FROM entries")
+ .map { Date(timeIntervalSince1970: $0) }
+ return StoreStats(textCount: textCount, imageCount: imageCount, shellCount: shellCount,
+ textBytes: textBytes, imageBytes: imageBytes, shellBytes: shellBytes,
+ pinnedCount: pinned,
+ eventsToday: events, duplicatesAvoidedToday: dups,
+ oldestEntry: oldest)
+ }
+
+ public nonisolated static func doctorChecks(dataDir: URL?) -> [(name: String, ok: Bool, detail: String)] {
+ var checks: [(name: String, ok: Bool, detail: String)] = []
+ let fm = FileManager.default
+ let dir = resolveDataDir(dataDir)
+
+ checks.append(dataDirectoryCheck(dir))
+ let database = databaseOpenCheck(dir.appendingPathComponent("clap.sqlite").path)
+ checks.append(database.check)
+ checks.append(requiredTablesCheck(database.handle))
+ checks.append(requiredIndexesCheck(database.handle))
+ checks.append(ftsAvailabilityCheck())
+ checks.append(imagesDirectoryCheck(dir))
+ checks.append(diskSpaceCheck(probeURL: fm.fileExists(atPath: dir.path) ? dir : fm.homeDirectoryForCurrentUser))
+ checks.append(shellHistoryCheck(database.handle))
+
+ return checks
+ }
+
+ private static func dataDirectoryCheck(_ dir: URL) -> (name: String, ok: Bool, detail: String) {
+ var isDir: ObjCBool = false
+ let dirExists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) && isDir.boolValue
+ let dirWritable = dirExists && FileManager.default.isWritableFile(atPath: dir.path)
+ return ("data directory", dirWritable,
+ dirExists ? (dirWritable ? dir.path : "not writable: \(dir.path)")
+ : "missing: \(dir.path)")
+ }
+
+ private static func databaseOpenCheck(_ dbPath: String) -> (check: (name: String, ok: Bool, detail: String), handle: Database?) {
+ guard FileManager.default.fileExists(atPath: dbPath) else {
+ return (("database opens", false, "missing: \(dbPath)"), nil)
+ }
+ do {
+ let database = try Database(path: dbPath)
+ return (("database opens", true, dbPath), database)
+ } catch {
+ return (("database opens", false, "\(error)"), nil)
+ }
+ }
+
+ private static func requiredTablesCheck(_ database: Database?) -> (name: String, ok: Bool, detail: String) {
+ let requiredTables = ["entries", "entries_fts", "config", "stats_counters"]
+ guard let database else { return ("required tables", false, "database unavailable") }
+ let found = (try? database.scalarInt64(
+ "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('entries','entries_fts','config','stats_counters')"
+ )) ?? 0
+ let ok = found == Int64(requiredTables.count)
+ return ("required tables", ok,
+ ok ? requiredTables.joined(separator: ", ") : "found \(found) of \(requiredTables.count)")
+ }
+
+ private static func requiredIndexesCheck(_ database: Database?) -> (name: String, ok: Bool, detail: String) {
+ let requiredIndexes = ["idx_entries_hash", "idx_entries_lru", "idx_entries_type"]
+ guard let database else { return ("indexes", false, "database unavailable") }
+ let found = (try? database.scalarInt64(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index'"
+ + " AND name IN ('idx_entries_hash','idx_entries_lru','idx_entries_type')"
+ )) ?? 0
+ let ok = found == Int64(requiredIndexes.count)
+ return ("indexes", ok, ok ? requiredIndexes.joined(separator: ", ")
+ : "found \(found) of \(requiredIndexes.count)")
+ }
+
+ private static func ftsAvailabilityCheck() -> (name: String, ok: Bool, detail: String) {
+ let ftsAvailable: Bool = {
+ guard let probe = try? Database(path: ":memory:") else { return false }
+ return (try? probe.exec("CREATE VIRTUAL TABLE fts_probe USING fts5(x)")) != nil
+ }()
+ return ("FTS5 available", ftsAvailable,
+ ftsAvailable ? "fts5 module present" : "fts5 module missing")
+ }
+
+ private static func imagesDirectoryCheck(_ dir: URL) -> (name: String, ok: Bool, detail: String) {
+ let imagesPath = dir.appendingPathComponent("images", isDirectory: true).path
+ var imagesIsDir: ObjCBool = false
+ let imagesOK = FileManager.default.fileExists(atPath: imagesPath, isDirectory: &imagesIsDir)
+ && imagesIsDir.boolValue && FileManager.default.isWritableFile(atPath: imagesPath)
+ return ("images directory", imagesOK, imagesOK ? imagesPath : "missing or not writable: \(imagesPath)")
+ }
+
+ private static func diskSpaceCheck(probeURL: URL) -> (name: String, ok: Bool, detail: String) {
+ if let values = try? probeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
+ let free = values.volumeAvailableCapacityForImportantUsage {
+ let ok = free > CoreConstants.minDiskFreeBytes
+ return ("disk space", ok, "\(ByteSize.format(free)) free")
+ }
+ return ("disk space", false, "unable to determine free space")
+ }
+
+ private static func shellHistoryCheck(_ database: Database?) -> (name: String, ok: Bool, detail: String) {
+ let shellHistfile: URL? = {
+ if let custom = try? database?.scalarText("SELECT value FROM config WHERE key = ?",
+ [.text(ConfigKey.shellHistfile)]),
+ !custom.trimmingCharacters(in: .whitespaces).isEmpty {
+ return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath)
+ }
+ return ShellHistoryParser.defaultHistoryFile()
+ }()
+ if let shellHistfile {
+ let readable = FileManager.default.isReadableFile(atPath: shellHistfile.path)
+ return ("shell history", readable, readable ? shellHistfile.path : "not readable: \(shellHistfile.path)")
+ }
+ return ("shell history", true, "no ~/.zsh_history or ~/.bash_history found (auto-detect)")
+ }
+}
diff --git a/Sources/ClapCore/ClipboardStore+Maintenance.swift b/Sources/ClapCore/ClipboardStore+Maintenance.swift
new file mode 100644
index 0000000..38af0a9
--- /dev/null
+++ b/Sources/ClapCore/ClipboardStore+Maintenance.swift
@@ -0,0 +1,201 @@
+import Foundation
+import CoreGraphics
+import ImageIO
+import UniformTypeIdentifiers
+
+// MARK: - Maintenance: LRU eviction, retention, vacuum, image files
+
+extension ClipboardStore {
+
+ /// LRU eviction per category: enforce max_entries (count) and max_size
+ /// (bytes). Pinned rows count toward usage but are never evicted.
+ /// Returns total evicted count.
+ @discardableResult
+ public func enforceLimits() throws -> Int {
+ var evicted = 0
+ for type in EntryType.allCases {
+ let maxEntries = try configInt("\(type.rawValue).max_entries")
+ let maxSize = try configInt64("\(type.rawValue).max_size")
+ var victims: [ClipboardEntry] = []
+
+ try db.transaction {
+ // Budgets apply to NON-PINNED rows only. If pinned rows were
+ // counted, enough pinned entries would permanently starve
+ // capture: every new unpinned entry would be evicted within
+ // one maintenance cycle. Pinned entries live outside the
+ // budget and are only ever removed explicitly.
+
+ // Count limit: lowest last_used_at first.
+ let total = Int(try db.scalarInt64(
+ "SELECT COUNT(*) FROM entries WHERE type = ? AND is_pinned = 0 AND is_favorite = 0",
+ [.text(type.rawValue)]) ?? 0)
+ if total > maxEntries {
+ let overflow = try db.query("""
+ SELECT \(Self.entryColumns) FROM entries
+ WHERE type = ? AND is_pinned = 0 AND is_favorite = 0
+ ORDER BY last_used_at ASC, id ASC LIMIT ?
+ """,
+ [.text(type.rawValue), .int(Int64(total - maxEntries))], Self.rowToEntry)
+ try deleteRowsInCurrentTransaction(overflow)
+ victims += overflow
+ }
+
+ // Oversize entries first: an entry bigger than the whole
+ // budget (possible after the user lowers max_size) must not
+ // survive while LRU eviction destroys every older entry
+ // chasing a cap it alone exceeds. Capture already rejects
+ // oversize content, so this is rare.
+ let oversize = try db.query("""
+ SELECT \(Self.entryColumns) FROM entries
+ WHERE type = ? AND is_pinned = 0 AND is_favorite = 0 AND size_bytes > ?
+ """, [.text(type.rawValue), .int(maxSize)], Self.rowToEntry)
+ if !oversize.isEmpty {
+ try deleteRowsInCurrentTransaction(oversize)
+ victims += oversize
+ }
+
+ victims += try evictByteOverage(type: type, maxSize: maxSize)
+ }
+ removeFiles(for: victims)
+ evicted += victims.count
+ }
+ return evicted
+ }
+
+ /// Byte limit over the remaining non-pinned, non-favorite rows. Fetched in
+ /// LRU-ordered batches — never all rows at once, which at 100k entries
+ /// would defeat the low-memory requirement. Must run inside a transaction.
+ private func evictByteOverage(type: EntryType, maxSize: Int64) throws -> [ClipboardEntry] {
+ var usage = try db.scalarInt64(
+ "SELECT COALESCE(SUM(size_bytes), 0) FROM entries WHERE type = ? AND is_pinned = 0 AND is_favorite = 0",
+ [.text(type.rawValue)]) ?? 0
+ guard usage > maxSize else { return [] }
+ var byteVictims: [ClipboardEntry] = []
+ // Keyset pagination on (last_used_at, id) — must match the ORDER BY
+ // exactly or LRU rows get skipped.
+ var lastUsed = -Double.greatestFiniteMagnitude
+ var lastID: Int64 = 0
+ outer: while usage > maxSize {
+ let batch = try db.query("""
+ SELECT \(Self.entryColumns) FROM entries
+ WHERE type = ? AND is_pinned = 0 AND is_favorite = 0
+ AND (last_used_at > ? OR (last_used_at = ? AND id > ?))
+ ORDER BY last_used_at ASC, id ASC LIMIT \(CoreConstants.sqlBatchSize)
+ """,
+ [.text(type.rawValue), .double(lastUsed), .double(lastUsed),
+ .int(lastID)], Self.rowToEntry)
+ if batch.isEmpty { break }
+ for candidate in batch {
+ guard usage > maxSize else { break outer }
+ byteVictims.append(candidate)
+ usage -= candidate.sizeBytes
+ }
+ if let last = batch.last {
+ lastUsed = last.lastUsedAt.timeIntervalSince1970
+ lastID = last.id
+ }
+ }
+ try deleteRowsInCurrentTransaction(byteVictims)
+ return byteVictims
+ }
+
+ /// Deletes non-pinned entries whose last_used_at is older than
+ /// retention.days (0 = never). Returns deleted count.
+ @discardableResult
+ public func applyRetention() throws -> Int {
+ let days = try configInt(ConfigKey.retentionDays)
+ guard days > 0 else { return 0 }
+ let cutoff = clock().timeIntervalSince1970 - Double(days) * CoreConstants.secondsPerDay
+ // Batched: expired rows can be the majority of a 50MB corpus, and
+ // their full content must never sit in memory at once.
+ var removed = 0
+ while true {
+ let victims = try db.query("""
+ SELECT \(Self.entryColumns) FROM entries
+ WHERE is_pinned = 0 AND is_favorite = 0 AND last_used_at < ? LIMIT \(CoreConstants.sqlBatchSize)
+ """, [.double(cutoff)], Self.rowToEntry)
+ guard !victims.isEmpty else { break }
+ try db.transaction {
+ try deleteRowsInCurrentTransaction(victims)
+ }
+ removeFiles(for: victims)
+ removed += victims.count
+ }
+ return removed
+ }
+
+ /// WAL checkpoint (TRUNCATE) always; full VACUUM only when the freelist
+ /// is large.
+ public func vacuumIfNeeded() throws {
+ try db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
+ let freelist = try db.scalarInt64("PRAGMA freelist_count") ?? 0
+ if freelist > CoreConstants.vacuumFreelistPageThreshold {
+ try db.exec("VACUUM")
+ }
+ }
+
+ // MARK: - Image helpers
+
+ public func imageFileURL(for entry: ClipboardEntry) -> URL? {
+ guard entry.type == .image, let path = entry.imagePath else { return nil }
+ return dataDir.appendingPathComponent("images", isDirectory: true)
+ .appendingPathComponent(path)
+ }
+
+ /// Lazily generates thumbnails/.png (max edge from constants) via
+ /// ImageIO. Returns nil for text entries.
+ public func thumbnailURL(for entry: ClipboardEntry) throws -> URL? {
+ guard entry.type == .image, let source = imageFileURL(for: entry) else { return nil }
+ let thumbsDir = dataDir.appendingPathComponent("thumbnails", isDirectory: true)
+ let thumbURL = thumbsDir.appendingPathComponent("\(entry.contentHash).png")
+ if FileManager.default.fileExists(atPath: thumbURL.path) { return thumbURL }
+ guard FileManager.default.fileExists(atPath: source.path) else {
+ throw ClapCoreError.io("image file missing at \(source.path)")
+ }
+ guard let imageSource = CGImageSourceCreateWithURL(source as CFURL, nil) else {
+ throw ClapCoreError.io("unable to read image at \(source.path)")
+ }
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceThumbnailMaxPixelSize: CoreConstants.thumbnailMaxPixelSize,
+ kCGImageSourceCreateThumbnailWithTransform: true
+ ]
+ guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
+ throw ClapCoreError.io("thumbnail generation failed for \(source.lastPathComponent)")
+ }
+ // Write via a same-directory temp file + rename: the app and the CLI
+ // are separate processes sharing this directory, and a torn PNG at the
+ // final path would be trusted forever by the fileExists check above.
+ let tempURL = thumbsDir.appendingPathComponent(".\(entry.contentHash).\(UUID().uuidString).tmp")
+ guard let destination = CGImageDestinationCreateWithURL(
+ tempURL as CFURL, UTType.png.identifier as CFString, 1, nil
+ ) else {
+ throw ClapCoreError.io("unable to create thumbnail at \(thumbURL.path)")
+ }
+ CGImageDestinationAddImage(destination, thumbnail, nil)
+ guard CGImageDestinationFinalize(destination) else {
+ removeTempFile(tempURL)
+ throw ClapCoreError.io("unable to finalize thumbnail at \(thumbURL.path)")
+ }
+ try? FileManager.default.setAttributes(CoreConstants.ownerOnlyFileAttributes,
+ ofItemAtPath: tempURL.path)
+ do {
+ _ = try FileManager.default.replaceItemAt(thumbURL, withItemAt: tempURL)
+ } catch {
+ removeTempFile(tempURL)
+ // Lost a race with another process writing the same thumbnail.
+ if !FileManager.default.fileExists(atPath: thumbURL.path) {
+ throw ClapCoreError.io("unable to move thumbnail into place at \(thumbURL.path)")
+ }
+ }
+ return thumbURL
+ }
+
+ private func removeTempFile(_ url: URL) {
+ do {
+ try FileManager.default.removeItem(at: url)
+ } catch {
+ Self.logger.error("temp cleanup failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+}
diff --git a/Sources/ClapCore/ClipboardStore+Mutations.swift b/Sources/ClapCore/ClipboardStore+Mutations.swift
new file mode 100644
index 0000000..70e1bb0
--- /dev/null
+++ b/Sources/ClapCore/ClipboardStore+Mutations.swift
@@ -0,0 +1,193 @@
+import Foundation
+
+// MARK: - Mutations: touch, delete, pin/favorite, shortcuts, tags, clear
+
+extension ClipboardStore {
+
+ public func touch(id: Int64) throws {
+ try touchRow(id: id, at: clock().timeIntervalSince1970)
+ }
+
+ @discardableResult
+ public func delete(id: Int64) throws -> Bool {
+ guard let victim = try firstEntry("id = ?", [.int(id)]) else { return false }
+ try db.run("DELETE FROM entries WHERE id = ?", [.int(id)])
+ removeFiles(for: [victim])
+ return true
+ }
+
+ /// Deletes the entry whose normalized text matches exactly (hash lookup).
+ @discardableResult
+ public func deleteMatching(text: String) throws -> Int {
+ let normalized = TextNormalizer.normalize(text)
+ guard !normalized.isEmpty else { return 0 }
+ let hash = ContentHasher.textHash(normalized)
+ try db.run("DELETE FROM entries WHERE type = 'text' AND content_hash = ?", [.text(hash)])
+ return db.changes
+ }
+
+ /// Deletes all content-bearing entries whose content matches the regex.
+ /// Batched candidate scan; all deletes in one transaction.
+ @discardableResult
+ public func deleteMatching(regexPattern: String) throws -> Int {
+ let regex = try SafeRegex.compile(regexPattern)
+ var victimIDs: [Int64] = []
+ try scanTextEntries(pinnedOnly: false) { entry in
+ if let content = entry.content, SafeRegex.matches(regex, in: content) {
+ victimIDs.append(entry.id)
+ }
+ return true // keep scanning until the scan cap
+ }
+ guard !victimIDs.isEmpty else { return 0 }
+ var victims: [ClipboardEntry] = []
+ try db.transaction {
+ victims = try fetchEntries(ids: victimIDs)
+ try deleteRowsInCurrentTransaction(victims)
+ }
+ removeFiles(for: victims)
+ return victims.count
+ }
+
+ private func fetchEntries(ids: [Int64]) throws -> [ClipboardEntry] {
+ var result: [ClipboardEntry] = []
+ for chunk in ids.chunked(CoreConstants.sqlBatchSize) {
+ let placeholders = Array(repeating: "?", count: chunk.count).joined(separator: ",")
+ result += try db.query(
+ "SELECT \(Self.entryColumns) FROM entries WHERE id IN (\(placeholders))",
+ chunk.map(SQLValue.int), Self.rowToEntry)
+ }
+ return result
+ }
+
+ @discardableResult
+ public func setPinned(_ pinned: Bool, id: Int64) throws -> Bool {
+ try db.run("UPDATE entries SET is_pinned = ? WHERE id = ?", [.int(pinned ? 1 : 0), .int(id)])
+ return db.changes > 0
+ }
+
+ @discardableResult
+ public func setFavorite(_ favorite: Bool, id: Int64) throws -> Bool {
+ try db.run("UPDATE entries SET is_favorite = ? WHERE id = ?", [.int(favorite ? 1 : 0), .int(id)])
+ return db.changes > 0
+ }
+
+ /// Sets or removes a trigger shortcut (e.g. ";email") for an entry.
+ @discardableResult
+ public func setShortcut(_ shortcut: String?, id: Int64) throws -> Bool {
+ let trimmed = shortcut?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalized = (trimmed?.isEmpty == false) ? trimmed : nil
+ try db.run("UPDATE entries SET shortcut = ? WHERE id = ?",
+ [normalized.map(SQLValue.text) ?? .null, .int(id)])
+ return db.changes > 0
+ }
+
+ /// Returns a dictionary of all active shortcuts mapping `shortcut -> expandedText`.
+ public func allShortcuts() throws -> [String: String] {
+ let rows = try db.query("""
+ SELECT shortcut, content FROM entries
+ WHERE shortcut IS NOT NULL AND shortcut != '' AND content IS NOT NULL AND content != ''
+ """, [], { stmt in
+ (stmt.text(0) ?? "", stmt.text(1) ?? "")
+ })
+ var map: [String: String] = [:]
+ for (shortcut, content) in rows where !shortcut.isEmpty && !content.isEmpty {
+ map[shortcut] = content
+ }
+ return map
+ }
+
+ // MARK: - Tags / Pinboards
+
+ /// Adds a tag to an entry (e.g. "code", "work"). Strips leading '#' and whitespace.
+ @discardableResult
+ public func addTag(_ rawTag: String, entryID: Int64) throws -> Bool {
+ guard let tag = Self.normalizeTag(rawTag) else { return false }
+ try db.run("""
+ INSERT OR IGNORE INTO entry_tags (entry_id, tag, created_at)
+ VALUES (?, ?, ?)
+ """, [.int(entryID), .text(tag), .double(clock().timeIntervalSince1970)])
+ return db.changes > 0
+ }
+
+ /// Removes a tag from an entry.
+ @discardableResult
+ public func removeTag(_ rawTag: String, entryID: Int64) throws -> Bool {
+ guard let tag = Self.normalizeTag(rawTag) else { return false }
+ try db.run("DELETE FROM entry_tags WHERE entry_id = ? AND tag = ? COLLATE NOCASE",
+ [.int(entryID), .text(tag)])
+ return db.changes > 0
+ }
+
+ /// Sets the full list of tags for an entry, replacing any previous tags.
+ public func setTags(_ tags: [String], entryID: Int64) throws {
+ var seen = Set()
+ var unique: [String] = []
+ for rawTag in tags {
+ guard let tag = Self.normalizeTag(rawTag), !seen.contains(tag) else { continue }
+ seen.insert(tag)
+ unique.append(tag)
+ }
+ let now = clock().timeIntervalSince1970
+ try db.transaction {
+ try db.run("DELETE FROM entry_tags WHERE entry_id = ?", [.int(entryID)])
+ for t in unique {
+ try db.run("INSERT OR IGNORE INTO entry_tags (entry_id, tag, created_at) VALUES (?, ?, ?)",
+ [.int(entryID), .text(t), .double(now)])
+ }
+ }
+ }
+
+ /// Returns all tags for a specific entry.
+ public func tags(for entryID: Int64) throws -> [String] {
+ try db.query("SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag COLLATE NOCASE ASC",
+ [.int(entryID)]) { $0.text(0) ?? "" }
+ }
+
+ /// Returns all distinct tags across the store with their respective entry counts.
+ public func allTags() throws -> [(tag: String, count: Int)] {
+ try db.query("""
+ SELECT tag, COUNT(*) as count FROM entry_tags
+ GROUP BY tag COLLATE NOCASE
+ ORDER BY tag COLLATE NOCASE ASC
+ """) { stmt in
+ (tag: stmt.text(0) ?? "", count: Int(stmt.int64(1)))
+ }
+ }
+
+ /// Removes every entry (counters are kept) and wipes the contents of
+ /// the images/ and thumbnails/ directories. Returns removed row count.
+ @discardableResult
+ public func clearAll() throws -> Int {
+ let removed = try db.transaction {
+ let count = Int(try db.scalarInt64("SELECT COUNT(*) FROM entries") ?? 0)
+ try db.run("DELETE FROM entries")
+ return count
+ }
+ wipeDirectoryContents(imagesDirectory)
+ wipeDirectoryContents(thumbnailsDirectory)
+ // "Clear" must actually clear: without a checkpoint the deleted
+ // clipboard text stays recoverable in the WAL until the next
+ // maintenance pass.
+ do {
+ try db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
+ } catch {
+ Self.logger.error("post-clear WAL checkpoint failed: \(error.localizedDescription, privacy: .public)")
+ }
+ return removed
+ }
+
+ private func wipeDirectoryContents(_ dir: URL) {
+ let fm = FileManager.default
+ guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else {
+ Self.logger.error("clear could not list \(dir.lastPathComponent, privacy: .public)")
+ return
+ }
+ for url in contents {
+ do {
+ try fm.removeItem(at: url)
+ } catch {
+ Self.logger.error("wipe failed in \(dir.lastPathComponent, privacy: .public): \(error.localizedDescription)")
+ }
+ }
+ }
+}
diff --git a/Sources/ClapCore/ClipboardStore+Query.swift b/Sources/ClapCore/ClipboardStore+Query.swift
new file mode 100644
index 0000000..987e103
--- /dev/null
+++ b/Sources/ClapCore/ClipboardStore+Query.swift
@@ -0,0 +1,172 @@
+import Foundation
+
+// MARK: - Queries: list, FTS search, regex search
+
+extension ClipboardStore {
+
+ public func list(type: EntryType?, limit: Int, offset: Int) throws -> [ClipboardEntry] {
+ try filteredList(SearchQuery(type: type, limit: limit, offset: offset))
+ }
+
+ public func search(_ query: SearchQuery) throws -> [ClipboardEntry] {
+ if let pattern = query.regex {
+ return try regexSearch(pattern, query: query)
+ }
+ if let text = query.text {
+ let tokens = QueryTokenizer.tokenize(text)
+ if !tokens.isEmpty {
+ return try ftsSearch(tokens, query: query)
+ }
+ }
+ return try filteredList(query)
+ }
+
+ public func entry(id: Int64) throws -> ClipboardEntry? {
+ try firstEntry("id = ?", [.int(id)])
+ }
+
+ public func count(type: EntryType?) throws -> Int {
+ if let type {
+ return Int(try db.scalarInt64("SELECT COUNT(*) FROM entries WHERE type = ?",
+ [.text(type.rawValue)]) ?? 0)
+ }
+ return Int(try db.scalarInt64("SELECT COUNT(*) FROM entries") ?? 0)
+ }
+
+ // MARK: - Search internals
+
+ /// `type IN (…)` fragment + binds for the query's effective type filter.
+ static func typeFilter(_ query: SearchQuery,
+ column: String = "type") -> (sql: String, binds: [SQLValue])? {
+ guard let types = query.effectiveTypes, !types.isEmpty else { return nil }
+ let placeholders = Array(repeating: "?", count: types.count).joined(separator: ", ")
+ return ("\(column) IN (\(placeholders))", types.map { SQLValue.text($0.rawValue) })
+ }
+
+ func filteredList(_ query: SearchQuery) throws -> [ClipboardEntry] {
+ var conditions: [String] = []
+ var binds: [SQLValue] = []
+ if let filter = Self.typeFilter(query) {
+ conditions.append(filter.sql)
+ binds.append(contentsOf: filter.binds)
+ }
+ if query.pinnedOnly { conditions.append("is_pinned = 1") }
+ if query.favoriteOnly { conditions.append("is_favorite = 1") }
+ if let tag = Self.normalizeTag(query.tag ?? "") {
+ conditions.append("id IN (SELECT entry_id FROM entry_tags WHERE tag = ? COLLATE NOCASE)")
+ binds.append(.text(tag))
+ }
+ var sql = "SELECT \(Self.entryColumns) FROM entries"
+ if !conditions.isEmpty { sql += " WHERE " + conditions.joined(separator: " AND ") }
+ sql += " ORDER BY last_used_at DESC, id DESC LIMIT ? OFFSET ?"
+ binds.append(.int(Int64(max(0, query.limit))))
+ binds.append(.int(Int64(max(0, query.offset))))
+ return try db.query(sql, binds, Self.rowToEntry)
+ }
+
+ /// Builds an FTS5 MATCH expression: bare terms become `"term"*` (prefix)
+ /// AND-joined; quoted phrases stay phrases. Double quotes are escaped.
+ static func ftsMatchExpression(_ tokens: [QueryTokenizer.Token]) -> String {
+ tokens.map { token in
+ let escaped = token.value.replacingOccurrences(of: "\"", with: "\"\"")
+ return token.quoted ? "\"\(escaped)\"" : "\"\(escaped)\"*"
+ }.joined(separator: " ")
+ }
+
+ func ftsSearch(_ tokens: [QueryTokenizer.Token], query: SearchQuery) throws -> [ClipboardEntry] {
+ let match = Self.ftsMatchExpression(tokens)
+ var sql = """
+ SELECT \(Self.prefixedEntryColumns) FROM entries e
+ JOIN entries_fts ON entries_fts.rowid = e.id
+ WHERE entries_fts MATCH ?
+ """
+ var binds: [SQLValue] = [.text(match)]
+ if let filter = Self.typeFilter(query, column: "e.type") {
+ sql += " AND \(filter.sql)"
+ binds.append(contentsOf: filter.binds)
+ }
+ if query.pinnedOnly { sql += " AND e.is_pinned = 1" }
+ if query.favoriteOnly { sql += " AND e.is_favorite = 1" }
+ if let tag = Self.normalizeTag(query.tag ?? "") {
+ sql += " AND e.id IN (SELECT entry_id FROM entry_tags WHERE tag = ? COLLATE NOCASE)"
+ binds.append(.text(tag))
+ }
+ sql += " ORDER BY e.last_used_at DESC, e.id DESC LIMIT ? OFFSET ?"
+ binds.append(.int(Int64(max(0, query.limit))))
+ binds.append(.int(Int64(max(0, query.offset))))
+ return try db.query(sql, binds, Self.rowToEntry)
+ }
+
+ static let regexScanCap = 20_000
+ /// Wall-clock budget for a whole regex scan. Length caps alone don't
+ /// bound catastrophic backtracking; without this a pathological pattern
+ /// could stall the store actor (and with it, clipboard capture).
+ static let regexScanTimeBudget: TimeInterval = 2.0
+
+ /// Batched candidate scan over content-bearing entries in last_used_at
+ /// DESC order. Calls `visit` per row; stops when `visit` returns false,
+ /// the scan cap is reached, or the time budget is exhausted (partial
+ /// results).
+ func scanTextEntries(pinnedOnly: Bool, favoriteOnly: Bool = false, tag: String? = nil,
+ contentType: EntryType? = nil,
+ _ visit: (ClipboardEntry) throws -> Bool) throws {
+ var scanned = 0
+ var dbOffset = 0
+ let deadline = clock().addingTimeInterval(Self.regexScanTimeBudget)
+ let cleanedTag = Self.normalizeTag(tag ?? "")
+ while scanned < Self.regexScanCap {
+ if clock() >= deadline { return }
+ // Regex scans every content-bearing type (text + shell).
+ var sql = "SELECT \(Self.entryColumns) FROM entries WHERE type IN ('text', 'shell')"
+ var binds: [SQLValue] = []
+ if let only = contentType {
+ sql += " AND type = ?"
+ binds.append(.text(only.rawValue))
+ }
+ if pinnedOnly { sql += " AND is_pinned = 1" }
+ if favoriteOnly { sql += " AND is_favorite = 1" }
+ if let cleanedTag {
+ sql += " AND id IN (SELECT entry_id FROM entry_tags WHERE tag = ? COLLATE NOCASE)"
+ binds.append(.text(cleanedTag))
+ }
+ sql += " ORDER BY last_used_at DESC, id DESC LIMIT ? OFFSET ?"
+ binds.append(.int(Int64(CoreConstants.sqlBatchSize)))
+ binds.append(.int(Int64(dbOffset)))
+ let batch = try db.query(sql, binds, Self.rowToEntry)
+ if batch.isEmpty { return }
+ for entry in batch {
+ scanned += 1
+ if try !visit(entry) { return }
+ if scanned >= Self.regexScanCap { return }
+ if clock() >= deadline { return }
+ }
+ dbOffset += batch.count
+ if batch.count < CoreConstants.sqlBatchSize { return }
+ }
+ }
+
+ func regexSearch(_ pattern: String, query: SearchQuery) throws -> [ClipboardEntry] {
+ // Regex applies to content-bearing entries only (text and shell).
+ let allowed = (query.effectiveTypes ?? [.text, .shell]).subtracting([.image])
+ if allowed.isEmpty { return [] }
+ let single = allowed.count == 1 ? allowed.first : nil
+ let regex = try SafeRegex.compile(pattern)
+ var results: [ClipboardEntry] = []
+ var toSkip = max(0, query.offset)
+ let limit = max(0, query.limit)
+ guard limit > 0 else { return [] }
+ try scanTextEntries(pinnedOnly: query.pinnedOnly, favoriteOnly: query.favoriteOnly,
+ tag: query.tag, contentType: single) { entry in
+ if let content = entry.content, SafeRegex.matches(regex, in: content) {
+ if toSkip > 0 {
+ toSkip -= 1
+ } else {
+ results.append(entry)
+ if results.count >= limit { return false }
+ }
+ }
+ return true
+ }
+ return results
+ }
+}
diff --git a/Sources/ClapCore/ClipboardStore.swift b/Sources/ClapCore/ClipboardStore.swift
index 7a61a49..02b3726 100644
--- a/Sources/ClapCore/ClipboardStore.swift
+++ b/Sources/ClapCore/ClipboardStore.swift
@@ -2,6 +2,7 @@ import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
+import os
/// The single entry point. An actor so all DB access is serialized per process.
/// Multi-process safety comes from SQLite WAL + busy_timeout.
@@ -10,7 +11,11 @@ import UniformTypeIdentifiers
public actor ClipboardStore {
public nonisolated let dataDir: URL
- private let db: Database
+ let db: Database
+ let clock: @Sendable () -> Date
+ let ocr: any OCREngine
+
+ static let logger = Logger(subsystem: ClapIdentity.bundleID, category: "store")
private nonisolated var imagesDir: URL {
dataDir.appendingPathComponent("images", isDirectory: true)
@@ -18,23 +23,29 @@ public actor ClipboardStore {
private nonisolated var thumbnailsDir: URL {
dataDir.appendingPathComponent("thumbnails", isDirectory: true)
}
+ nonisolated var imagesDirectory: URL { imagesDir }
+ nonisolated var thumbnailsDirectory: URL { thumbnailsDir }
- public init(dataDir: URL? = nil) throws {
+ public init(dataDir: URL? = nil,
+ now: @escaping @Sendable () -> Date = { Date() },
+ ocr: any OCREngine = VisionOCREngine()) throws {
let resolved = Self.resolveDataDir(dataDir)
self.dataDir = resolved
+ self.clock = now
+ self.ocr = ocr
let fm = FileManager.default
- // Clipboard data is sensitive: owner-only on everything we create.
- let ownerOnly: [FileAttributeKey: Any] = [.posixPermissions: 0o700]
try fm.createDirectory(at: resolved, withIntermediateDirectories: true,
- attributes: ownerOnly)
+ attributes: CoreConstants.ownerOnlyDirectoryAttributes)
try fm.createDirectory(at: resolved.appendingPathComponent("images", isDirectory: true),
- withIntermediateDirectories: true, attributes: ownerOnly)
+ withIntermediateDirectories: true,
+ attributes: CoreConstants.ownerOnlyDirectoryAttributes)
try fm.createDirectory(at: resolved.appendingPathComponent("thumbnails", isDirectory: true),
- withIntermediateDirectories: true, attributes: ownerOnly)
+ withIntermediateDirectories: true,
+ attributes: CoreConstants.ownerOnlyDirectoryAttributes)
// Pre-existing dirs keep their old mode; tighten them too.
for dir in [resolved, resolved.appendingPathComponent("images"),
resolved.appendingPathComponent("thumbnails")] {
- try? fm.setAttributes(ownerOnly, ofItemAtPath: dir.path)
+ try? fm.setAttributes(CoreConstants.ownerOnlyDirectoryAttributes, ofItemAtPath: dir.path)
}
self.db = try Database(path: resolved.appendingPathComponent("clap.sqlite").path)
try db.migrate()
@@ -42,7 +53,7 @@ public actor ClipboardStore {
for suffix in ["", "-wal", "-shm"] {
let path = resolved.appendingPathComponent("clap.sqlite\(suffix)").path
if fm.fileExists(atPath: path) {
- try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
+ try? fm.setAttributes(CoreConstants.ownerOnlyFileAttributes, ofItemAtPath: path)
}
}
}
@@ -57,793 +68,32 @@ public actor ClipboardStore {
.appendingPathComponent("Library/Application Support/clap", isDirectory: true)
}
- // MARK: - Capture
-
- @discardableResult
- public func captureText(_ raw: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? {
- let normalized = TextNormalizer.normalize(raw)
- guard !normalized.isEmpty else { return nil }
- // A single entry larger than the whole category budget must never be
- // stored: byte eviction would otherwise delete every older unpinned
- // entry chasing a cap this entry alone exceeds.
- let sizeBytes = Int64(normalized.utf8.count)
- if sizeBytes > (try configInt64("text.max_size")) { return nil }
- let hash = ContentHasher.textHash(normalized)
- let now = Date().timeIntervalSince1970
- let day = Self.dayKey()
-
- return try db.transaction {
- try incrementCounter("events:\(day)")
- // content equality guards against a (rare) 64-bit hash collision
- // silently discarding unrelated text as a "duplicate".
- if let existing = try firstEntry("type = 'text' AND content_hash = ? AND content = ?",
- [.text(hash), .text(normalized)]) {
- try db.run("UPDATE entries SET last_used_at = ?, use_count = use_count + 1 WHERE id = ?",
- [.double(now), .int(existing.id)])
- try incrementCounter("dups:\(day)")
- guard let updated = try firstEntry("id = ?", [.int(existing.id)]) else {
- throw ClapCoreError.database(code: 0, message: "entry vanished during capture")
- }
- return (updated, true)
- }
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('text', ?, NULL, NULL, ?, ?, ?, ?, 0, 1, ?)
- """,
- [.text(normalized), .text(hash), .double(now), .double(now),
- .int(Int64(normalized.utf8.count)), sourceApp.map(SQLValue.text) ?? .null])
- guard let inserted = try firstEntry("id = ?", [.int(db.lastInsertRowid)]) else {
- throw ClapCoreError.database(code: 0, message: "insert did not produce a row")
- }
- return (inserted, false)
- }
- }
-
- @discardableResult
- public func captureImage(data: Data, format: String, sourceApp: String?) throws -> (entry: ClipboardEntry, wasDuplicate: Bool)? {
- guard !data.isEmpty else { return nil }
- // Same oversize guard as text: never store an entry bigger than the
- // whole category budget (see captureText).
- if Int64(data.count) > (try configInt64("image.max_size")) { return nil }
- let hash = ContentHasher.imageHash(data)
- let ext = format.lowercased()
- let relativePath = "\(hash).\(ext)"
- let now = Date().timeIntervalSince1970
- let day = Self.dayKey()
-
- return try db.transaction {
- try incrementCounter("events:\(day)")
- if let existing = try firstEntry("type = 'image' AND content_hash = ?", [.text(hash)]) {
- // Duplicate image: touch only, never rewrite the file.
- try db.run("UPDATE entries SET last_used_at = ?, use_count = use_count + 1 WHERE id = ?",
- [.double(now), .int(existing.id)])
- try incrementCounter("dups:\(day)")
- guard let updated = try firstEntry("id = ?", [.int(existing.id)]) else {
- throw ClapCoreError.database(code: 0, message: "entry vanished during capture")
- }
- return (updated, true)
- }
- // New image: write original bytes atomically (temp file + rename).
- let fileURL = imagesDir.appendingPathComponent(relativePath)
- do {
- try data.write(to: fileURL, options: .atomic)
- try? FileManager.default.setAttributes([.posixPermissions: 0o600],
- ofItemAtPath: fileURL.path)
- } catch {
- throw ClapCoreError.io("failed to write image file at \(fileURL.path)")
- }
- let ocrText = OCRScanner.recognizeText(from: data)
- do {
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('image', ?, ?, ?, ?, ?, ?, ?, 0, 1, ?)
- """,
- [ocrText.map(SQLValue.text) ?? .null, .text(relativePath), .text(ext), .text(hash), .double(now), .double(now),
- .int(Int64(data.count)), sourceApp.map(SQLValue.text) ?? .null])
- guard let inserted = try firstEntry("id = ?", [.int(db.lastInsertRowid)]) else {
- throw ClapCoreError.database(code: 0, message: "insert did not produce a row")
- }
- return (inserted, false)
- } catch {
- // The row rolls back with the transaction; the file must not
- // be left orphaned on disk.
- try? FileManager.default.removeItem(at: fileURL)
- throw error
- }
- }
- }
-
- // MARK: - Shell history
-
- /// Ingests one executed shell command (live watcher and backfill both use
- /// this). Dedup merges: re-running a command bumps recency and use_count
- /// instead of inserting a new row. Daily clipboard counters are NOT
- /// touched — commands aren't clipboard events. Returns nil when empty or
- /// oversize.
- @discardableResult
- public func ingestShell(_ command: String, executedAt: Date?,
- source: String? = nil) throws -> (id: Int64, merged: Bool)? {
- let normalized = TextNormalizer.normalize(command)
- guard !normalized.isEmpty else { return nil }
- let sizeBytes = Int64(normalized.utf8.count)
- if sizeBytes > (try configInt64("shell.max_size")) { return nil }
- let hash = ContentHasher.textHash(normalized)
- let when = executedAt ?? Date()
-
- return try db.transaction {
- if let existing = try firstEntry("type = 'shell' AND content_hash = ? AND content = ?",
- [.text(hash), .text(normalized)]) {
- try db.run("""
- UPDATE entries SET
- created_at = MIN(created_at, ?),
- last_used_at = MAX(last_used_at, ?),
- use_count = use_count + 1
- WHERE id = ?
- """,
- [.double(when.timeIntervalSince1970),
- .double(when.timeIntervalSince1970), .int(existing.id)])
- return (existing.id, true)
- }
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('shell', ?, NULL, NULL, ?, ?, ?, ?, 0, 1, ?)
- """,
- [.text(normalized), .text(hash),
- .double(when.timeIntervalSince1970), .double(when.timeIntervalSince1970),
- .int(sizeBytes), source.map(SQLValue.text) ?? .null])
- return (db.lastInsertRowid, false)
- }
- }
-
- /// Ingests a batch of shell commands within a single database transaction.
- @discardableResult
- public func ingestShellBatch(_ commands: [(text: String, executedAt: Date?)],
- source: String? = nil) throws -> (imported: Int, merged: Int) {
- guard !commands.isEmpty else { return (0, 0) }
- let maxSize = try configInt64("shell.max_size")
- var imported = 0
- var merged = 0
- try db.transaction {
- for item in commands {
- let normalized = TextNormalizer.normalize(item.text)
- guard !normalized.isEmpty else { continue }
- let sizeBytes = Int64(normalized.utf8.count)
- if sizeBytes > maxSize { continue }
- let hash = ContentHasher.textHash(normalized)
- let when = item.executedAt ?? Date()
+ // MARK: - Shared row plumbing
- if let existing = try firstEntry("type = 'shell' AND content_hash = ? AND content = ?",
- [.text(hash), .text(normalized)]) {
- try db.run("""
- UPDATE entries SET
- created_at = MIN(created_at, ?),
- last_used_at = MAX(last_used_at, ?),
- use_count = use_count + 1
- WHERE id = ?
- """,
- [.double(when.timeIntervalSince1970),
- .double(when.timeIntervalSince1970), .int(existing.id)])
- merged += 1
- } else {
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('shell', ?, NULL, NULL, ?, ?, ?, ?, 0, 1, ?)
- """,
- [.text(normalized), .text(hash),
- .double(when.timeIntervalSince1970), .double(when.timeIntervalSince1970),
- .int(sizeBytes), source.map(SQLValue.text) ?? .null])
- imported += 1
- }
- }
- }
- return (imported, merged)
- }
-
- // MARK: - Import
-
- /// Imports a text entry from another clipboard manager, preserving its
- /// history metadata. Unlike capture, this does not bump daily counters
- /// (imported rows are not today's clipboard events). Duplicates merge:
- /// earliest created_at, latest last_used_at, summed use_count, pin wins.
- /// Returns nil when the text is empty after normalization or oversize.
- @discardableResult
- public func importText(_ raw: String, createdAt: Date, lastUsedAt: Date,
- useCount: Int, pinned: Bool, sourceApp: String?) throws -> (id: Int64, merged: Bool)? {
- let normalized = TextNormalizer.normalize(raw)
- guard !normalized.isEmpty else { return nil }
- let sizeBytes = Int64(normalized.utf8.count)
- if sizeBytes > (try configInt64("text.max_size")) { return nil }
- let hash = ContentHasher.textHash(normalized)
-
- return try db.transaction {
- if let existing = try firstEntry("type = 'text' AND content_hash = ? AND content = ?",
- [.text(hash), .text(normalized)]) {
- try mergeImported(into: existing.id, createdAt: createdAt,
- lastUsedAt: lastUsedAt, useCount: useCount, pinned: pinned)
- return (existing.id, true)
- }
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('text', ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)
- """,
- [.text(normalized), .text(hash),
- .double(createdAt.timeIntervalSince1970), .double(lastUsedAt.timeIntervalSince1970),
- .int(sizeBytes), .int(pinned ? 1 : 0), .int(Int64(max(1, useCount))),
- sourceApp.map(SQLValue.text) ?? .null])
- return (db.lastInsertRowid, false)
- }
- }
-
- /// Image counterpart of `importText`. See its semantics.
- @discardableResult
- public func importImage(data: Data, format: String, createdAt: Date, lastUsedAt: Date,
- useCount: Int, pinned: Bool, sourceApp: String?) throws -> (id: Int64, merged: Bool)? {
- guard !data.isEmpty else { return nil }
- if Int64(data.count) > (try configInt64("image.max_size")) { return nil }
- let hash = ContentHasher.imageHash(data)
- let ext = format.lowercased()
- let relativePath = "\(hash).\(ext)"
-
- return try db.transaction {
- if let existing = try firstEntry("type = 'image' AND content_hash = ?", [.text(hash)]) {
- try mergeImported(into: existing.id, createdAt: createdAt,
- lastUsedAt: lastUsedAt, useCount: useCount, pinned: pinned)
- return (existing.id, true)
- }
- let fileURL = imagesDir.appendingPathComponent(relativePath)
- do {
- try data.write(to: fileURL, options: .atomic)
- try? FileManager.default.setAttributes([.posixPermissions: 0o600],
- ofItemAtPath: fileURL.path)
- } catch {
- throw ClapCoreError.io("failed to write image file at \(fileURL.path)")
- }
- let ocrText = OCRScanner.recognizeText(from: data)
- do {
- try db.run("""
- INSERT INTO entries (type, content, image_path, image_format, content_hash,
- created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
- VALUES ('image', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- [ocrText.map(SQLValue.text) ?? .null, .text(relativePath), .text(ext), .text(hash),
- .double(createdAt.timeIntervalSince1970), .double(lastUsedAt.timeIntervalSince1970),
- .int(Int64(data.count)), .int(pinned ? 1 : 0), .int(Int64(max(1, useCount))),
- sourceApp.map(SQLValue.text) ?? .null])
- return (db.lastInsertRowid, false)
- } catch {
- try? FileManager.default.removeItem(at: fileURL)
- throw error
- }
- }
- }
-
- /// Updates the extracted OCR text for an image entry.
- public func updateOCRText(for entryID: Int64, ocrText: String) throws {
- try db.run("UPDATE entries SET content = ? WHERE id = ? AND type = 'image'",
- [.text(ocrText), .int(entryID)])
- }
-
- /// Must run inside a transaction started by the caller.
- private func mergeImported(into id: Int64, createdAt: Date, lastUsedAt: Date,
- useCount: Int, pinned: Bool) throws {
- try db.run("""
- UPDATE entries SET
- created_at = MIN(created_at, ?),
- last_used_at = MAX(last_used_at, ?),
- use_count = use_count + ?,
- is_pinned = MAX(is_pinned, ?)
- WHERE id = ?
- """,
- [.double(createdAt.timeIntervalSince1970), .double(lastUsedAt.timeIntervalSince1970),
- .int(Int64(max(1, useCount))), .int(pinned ? 1 : 0), .int(id)])
- }
-
- // MARK: - Queries
-
- public func list(type: EntryType?, limit: Int, offset: Int) throws -> [ClipboardEntry] {
- try filteredList(SearchQuery(type: type, limit: limit, offset: offset))
- }
-
- public func search(_ query: SearchQuery) throws -> [ClipboardEntry] {
- if let pattern = query.regex {
- return try regexSearch(pattern, query: query)
- }
- if let text = query.text {
- let tokens = QueryTokenizer.tokenize(text)
- if !tokens.isEmpty {
- return try ftsSearch(tokens, query: query)
- }
- }
- return try filteredList(query)
- }
-
- public func entry(id: Int64) throws -> ClipboardEntry? {
- try firstEntry("id = ?", [.int(id)])
- }
-
- public func count(type: EntryType?) throws -> Int {
- if let type {
- return Int(try db.scalarInt64("SELECT COUNT(*) FROM entries WHERE type = ?",
- [.text(type.rawValue)]) ?? 0)
- }
- return Int(try db.scalarInt64("SELECT COUNT(*) FROM entries") ?? 0)
- }
-
- // MARK: - Mutations
-
- public func touch(id: Int64) throws {
- try db.run("UPDATE entries SET last_used_at = ?, use_count = use_count + 1 WHERE id = ?",
- [.double(Date().timeIntervalSince1970), .int(id)])
- }
-
- @discardableResult
- public func delete(id: Int64) throws -> Bool {
- guard let victim = try firstEntry("id = ?", [.int(id)]) else { return false }
- try db.run("DELETE FROM entries WHERE id = ?", [.int(id)])
- removeFiles(for: [victim])
- return true
- }
-
- /// Deletes the entry whose normalized text matches exactly (hash lookup).
- @discardableResult
- public func deleteMatching(text: String) throws -> Int {
- let normalized = TextNormalizer.normalize(text)
- guard !normalized.isEmpty else { return 0 }
- let hash = ContentHasher.textHash(normalized)
- try db.run("DELETE FROM entries WHERE type = 'text' AND content_hash = ?", [.text(hash)])
- return db.changes
- }
-
- /// Deletes all text entries whose content matches the regex.
- /// Batched candidate scan; all deletes in one transaction.
- @discardableResult
- public func deleteMatching(regexPattern: String) throws -> Int {
- let regex = try SafeRegex.compile(regexPattern)
- var victimIDs: [Int64] = []
- try scanTextEntries(pinnedOnly: false) { entry in
- if let content = entry.content, SafeRegex.matches(regex, in: content) {
- victimIDs.append(entry.id)
- }
- return true // keep scanning until the scan cap
- }
- guard !victimIDs.isEmpty else { return 0 }
- try db.transaction {
- for chunk in victimIDs.chunked(500) {
- let placeholders = Array(repeating: "?", count: chunk.count).joined(separator: ",")
- try db.run("DELETE FROM entries WHERE id IN (\(placeholders))", chunk.map(SQLValue.int))
- }
- }
- return victimIDs.count
- }
-
- @discardableResult
- public func setPinned(_ pinned: Bool, id: Int64) throws -> Bool {
- try db.run("UPDATE entries SET is_pinned = ? WHERE id = ?", [.int(pinned ? 1 : 0), .int(id)])
- return db.changes > 0
- }
-
- @discardableResult
- public func setFavorite(_ favorite: Bool, id: Int64) throws -> Bool {
- try db.run("UPDATE entries SET is_favorite = ? WHERE id = ?", [.int(favorite ? 1 : 0), .int(id)])
- return db.changes > 0
- }
-
- /// Sets or removes a trigger shortcut (e.g. ";email") for an entry.
- @discardableResult
- public func setShortcut(_ shortcut: String?, id: Int64) throws -> Bool {
- let trimmed = shortcut?.trimmingCharacters(in: .whitespacesAndNewlines)
- let normalized = (trimmed?.isEmpty == false) ? trimmed : nil
- try db.run("UPDATE entries SET shortcut = ? WHERE id = ?",
- [normalized.map(SQLValue.text) ?? .null, .int(id)])
- return db.changes > 0
- }
-
- /// Returns a dictionary of all active shortcuts mapping `shortcut -> expandedText`.
- public func allShortcuts() throws -> [String: String] {
- let rows = try db.query("""
- SELECT shortcut, content FROM entries
- WHERE shortcut IS NOT NULL AND shortcut != '' AND content IS NOT NULL AND content != ''
- """, [], { stmt in
- (stmt.text(0) ?? "", stmt.text(1) ?? "")
- })
- var map: [String: String] = [:]
- for (shortcut, content) in rows {
- if !shortcut.isEmpty && !content.isEmpty {
- map[shortcut] = content
- }
- }
- return map
- }
-
- /// Removes every entry (counters are kept) and wipes the contents of
- /// the images/ and thumbnails/ directories. Returns removed row count.
- @discardableResult
- public func clearAll() throws -> Int {
- let removed = try db.transaction {
- let count = Int(try db.scalarInt64("SELECT COUNT(*) FROM entries") ?? 0)
- try db.run("DELETE FROM entries")
- return count
- }
- let fm = FileManager.default
- for dir in [imagesDir, thumbnailsDir] {
- if let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
- for url in contents { try? fm.removeItem(at: url) }
- }
- }
- // "Clear" must actually clear: without a checkpoint the deleted
- // clipboard text stays recoverable in the WAL until the next
- // maintenance pass.
- try? db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
- return removed
- }
-
- // MARK: - Maintenance
-
- /// LRU eviction per category: enforce max_entries (count) and max_size
- /// (bytes). Pinned rows count toward usage but are never evicted.
- /// Returns total evicted count.
- @discardableResult
- public func enforceLimits() throws -> Int {
- var evicted = 0
- for type in EntryType.allCases {
- let maxEntries = try configInt("\(type.rawValue).max_entries")
- let maxSize = try configInt64("\(type.rawValue).max_size")
- var victims: [ClipboardEntry] = []
-
- try db.transaction {
- // Budgets apply to NON-PINNED rows only. If pinned rows were
- // counted, enough pinned entries would permanently starve
- // capture: every new unpinned entry would be evicted within
- // one maintenance cycle. Pinned entries live outside the
- // budget and are only ever removed explicitly.
-
- // Count limit: lowest last_used_at first.
- let total = Int(try db.scalarInt64(
- "SELECT COUNT(*) FROM entries WHERE type = ? AND is_pinned = 0 AND is_favorite = 0",
- [.text(type.rawValue)]) ?? 0)
- if total > maxEntries {
- let overflow = try db.query("""
- SELECT \(Self.entryColumns) FROM entries
- WHERE type = ? AND is_pinned = 0 AND is_favorite = 0
- ORDER BY last_used_at ASC, id ASC LIMIT ?
- """,
- [.text(type.rawValue), .int(Int64(total - maxEntries))], Self.rowToEntry)
- try deleteRowsInCurrentTransaction(overflow)
- victims += overflow
- }
-
- // Oversize entries first: an entry bigger than the whole
- // budget (possible after the user lowers max_size) must not
- // survive while LRU eviction destroys every older entry
- // chasing a cap it alone exceeds. Capture already rejects
- // oversize content, so this is rare.
- let oversize = try db.query("""
- SELECT \(Self.entryColumns) FROM entries
- WHERE type = ? AND is_pinned = 0 AND is_favorite = 0 AND size_bytes > ?
- """, [.text(type.rawValue), .int(maxSize)], Self.rowToEntry)
- if !oversize.isEmpty {
- try deleteRowsInCurrentTransaction(oversize)
- victims += oversize
- }
-
- // Byte limit over the remaining non-pinned, non-favorite rows. Fetched in
- // LRU-ordered batches — never all rows at once, which at 100k
- // entries would defeat the low-memory requirement.
- var usage = try db.scalarInt64(
- "SELECT COALESCE(SUM(size_bytes), 0) FROM entries WHERE type = ? AND is_pinned = 0 AND is_favorite = 0",
- [.text(type.rawValue)]) ?? 0
- if usage > maxSize {
- var byteVictims: [ClipboardEntry] = []
- // Keyset pagination on (last_used_at, id) — must match the
- // ORDER BY exactly or LRU rows get skipped.
- var lastUsed = -Double.greatestFiniteMagnitude
- var lastID: Int64 = 0
- outer: while usage > maxSize {
- let batch = try db.query("""
- SELECT \(Self.entryColumns) FROM entries
- WHERE type = ? AND is_pinned = 0 AND is_favorite = 0
- AND (last_used_at > ? OR (last_used_at = ? AND id > ?))
- ORDER BY last_used_at ASC, id ASC LIMIT 500
- """,
- [.text(type.rawValue), .double(lastUsed), .double(lastUsed),
- .int(lastID)], Self.rowToEntry)
- if batch.isEmpty { break }
- for candidate in batch {
- guard usage > maxSize else { break outer }
- byteVictims.append(candidate)
- usage -= candidate.sizeBytes
- }
- if let last = batch.last {
- lastUsed = last.lastUsedAt.timeIntervalSince1970
- lastID = last.id
- }
- }
- try deleteRowsInCurrentTransaction(byteVictims)
- victims += byteVictims
- }
- }
- removeFiles(for: victims)
- evicted += victims.count
- }
- return evicted
- }
-
- /// Deletes non-pinned entries whose last_used_at is older than
- /// retention.days (0 = never). Returns deleted count.
- @discardableResult
- public func applyRetention() throws -> Int {
- let days = try configInt("retention.days")
- guard days > 0 else { return 0 }
- let cutoff = Date().timeIntervalSince1970 - Double(days) * 86_400
- // Batched: expired rows can be the majority of a 50MB corpus, and
- // their full content must never sit in memory at once.
- var removed = 0
- while true {
- let victims = try db.query("""
- SELECT \(Self.entryColumns) FROM entries
- WHERE is_pinned = 0 AND is_favorite = 0 AND last_used_at < ? LIMIT 500
- """, [.double(cutoff)], Self.rowToEntry)
- guard !victims.isEmpty else { break }
- try db.transaction {
- try deleteRowsInCurrentTransaction(victims)
- }
- removeFiles(for: victims)
- removed += victims.count
- }
- return removed
- }
-
- /// WAL checkpoint (TRUNCATE) always; full VACUUM only when the freelist
- /// is large (> 1000 pages).
- public func vacuumIfNeeded() throws {
- try db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
- let freelist = try db.scalarInt64("PRAGMA freelist_count") ?? 0
- if freelist > 1000 {
- try db.exec("VACUUM")
- }
- }
-
- // MARK: - Image helpers
-
- public func imageFileURL(for entry: ClipboardEntry) -> URL? {
- guard entry.type == .image, let path = entry.imagePath else { return nil }
- return imagesDir.appendingPathComponent(path)
- }
-
- /// Lazily generates thumbnails/.png (max 400 px long edge) via
- /// ImageIO. Returns nil for text entries.
- public func thumbnailURL(for entry: ClipboardEntry) throws -> URL? {
- guard entry.type == .image, let source = imageFileURL(for: entry) else { return nil }
- let thumbURL = thumbnailsDir.appendingPathComponent("\(entry.contentHash).png")
- if FileManager.default.fileExists(atPath: thumbURL.path) { return thumbURL }
- guard FileManager.default.fileExists(atPath: source.path) else {
- throw ClapCoreError.io("image file missing at \(source.path)")
- }
- guard let imageSource = CGImageSourceCreateWithURL(source as CFURL, nil) else {
- throw ClapCoreError.io("unable to read image at \(source.path)")
- }
- let options: [CFString: Any] = [
- kCGImageSourceCreateThumbnailFromImageAlways: true,
- kCGImageSourceThumbnailMaxPixelSize: 400,
- kCGImageSourceCreateThumbnailWithTransform: true,
- ]
- guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
- throw ClapCoreError.io("thumbnail generation failed for \(source.lastPathComponent)")
- }
- // Write via a same-directory temp file + rename: the app and the CLI
- // are separate processes sharing this directory, and a torn PNG at the
- // final path would be trusted forever by the fileExists check above.
- let tempURL = thumbnailsDir.appendingPathComponent(".\(entry.contentHash).\(UUID().uuidString).tmp")
- guard let destination = CGImageDestinationCreateWithURL(
- tempURL as CFURL, UTType.png.identifier as CFString, 1, nil
- ) else {
- throw ClapCoreError.io("unable to create thumbnail at \(thumbURL.path)")
- }
- CGImageDestinationAddImage(destination, thumbnail, nil)
- guard CGImageDestinationFinalize(destination) else {
- try? FileManager.default.removeItem(at: tempURL)
- throw ClapCoreError.io("unable to finalize thumbnail at \(thumbURL.path)")
- }
- try? FileManager.default.setAttributes([.posixPermissions: 0o600],
- ofItemAtPath: tempURL.path)
- do {
- _ = try FileManager.default.replaceItemAt(thumbURL, withItemAt: tempURL)
- } catch {
- try? FileManager.default.removeItem(at: tempURL)
- // Lost a race with another process writing the same thumbnail.
- if !FileManager.default.fileExists(atPath: thumbURL.path) {
- throw ClapCoreError.io("unable to move thumbnail into place at \(thumbURL.path)")
- }
- }
- return thumbURL
- }
-
- // MARK: - Settings / stats / doctor
-
- static let configDefaults: [String: String] = [
- "text.max_entries": "100000",
- "text.max_size": "52428800",
- "image.max_entries": "500",
- "image.max_size": "104857600",
- "monitoring.paused": "0",
- "exclusions": "[]",
- "retention.days": "0",
- "launch_at_login": "0",
- // Synthesize Cmd+V into the frontmost app after copying from the UI
- // (Maccy-style). Requires Accessibility permission; falls back to
- // copy-only when not granted.
- "paste.on_copy": "1",
- // Shell history (zsh/bash) ingestion.
- "shell.enabled": "1",
- "shell.max_entries": "50000",
- "shell.max_size": "10485760", // 10 MB
- "shell.histfile": "", // empty = auto-detect
+ private static let entryColumnNames = [
+ "id", "type", "content", "image_path", "image_format", "content_hash",
+ "created_at", "last_used_at", "size_bytes", "is_pinned", "is_favorite",
+ "use_count", "source_app", "shortcut"
]
- /// Returns the stored value, falling back to the documented default when
- /// the key is a known config key, else nil.
- public func config(_ key: String) throws -> String? {
- if let stored = try db.scalarText("SELECT value FROM config WHERE key = ?", [.text(key)]) {
- return stored
- }
- return Self.configDefaults[key]
- }
-
- public func setConfig(_ key: String, value: String) throws {
- try db.run("""
- INSERT INTO config (key, value) VALUES (?, ?)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
- """, [.text(key), .text(value)])
- }
-
- /// All config, with defaults merged in so every documented key appears.
- /// Sorted by key.
- public func allConfig() throws -> [(key: String, value: String)] {
- var merged = Self.configDefaults
- let stored = try db.query("SELECT key, value FROM config") {
- (key: $0.text(0) ?? "", value: $0.text(1) ?? "")
- }
- for row in stored { merged[row.key] = row.value }
- return merged.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
- }
-
- public func stats() throws -> StoreStats {
- var textCount = 0, imageCount = 0, shellCount = 0
- var textBytes: Int64 = 0, imageBytes: Int64 = 0, shellBytes: Int64 = 0
- _ = try db.query("SELECT type, COUNT(*), COALESCE(SUM(size_bytes), 0) FROM entries GROUP BY type") { stmt -> Void in
- switch stmt.text(0) {
- case "text":
- textCount = Int(stmt.int64(1)); textBytes = stmt.int64(2)
- case "image":
- imageCount = Int(stmt.int64(1)); imageBytes = stmt.int64(2)
- case "shell":
- shellCount = Int(stmt.int64(1)); shellBytes = stmt.int64(2)
- default:
- break
- }
- }
- let pinned = Int(try db.scalarInt64("SELECT COUNT(*) FROM entries WHERE is_pinned = 1") ?? 0)
- let day = Self.dayKey()
- let events = Int(try db.scalarInt64("SELECT value FROM stats_counters WHERE key = ?",
- [.text("events:\(day)")]) ?? 0)
- let dups = Int(try db.scalarInt64("SELECT value FROM stats_counters WHERE key = ?",
- [.text("dups:\(day)")]) ?? 0)
- let oldest = try db.scalarDouble("SELECT MIN(created_at) FROM entries")
- .map { Date(timeIntervalSince1970: $0) }
- return StoreStats(textCount: textCount, imageCount: imageCount, shellCount: shellCount,
- textBytes: textBytes, imageBytes: imageBytes, shellBytes: shellBytes,
- pinnedCount: pinned,
- eventsToday: events, duplicatesAvoidedToday: dups,
- oldestEntry: oldest)
- }
-
- public nonisolated static func doctorChecks(dataDir: URL?) -> [(name: String, ok: Bool, detail: String)] {
- var checks: [(name: String, ok: Bool, detail: String)] = []
- let fm = FileManager.default
- let dir = resolveDataDir(dataDir)
-
- // 1. Data dir exists and is writable.
- var isDir: ObjCBool = false
- let dirExists = fm.fileExists(atPath: dir.path, isDirectory: &isDir) && isDir.boolValue
- let dirWritable = dirExists && fm.isWritableFile(atPath: dir.path)
- checks.append(("data directory", dirWritable,
- dirExists ? (dirWritable ? dir.path : "not writable: \(dir.path)")
- : "missing: \(dir.path)"))
-
- // 2. Database opens.
- let dbPath = dir.appendingPathComponent("clap.sqlite").path
- var database: Database?
- if fm.fileExists(atPath: dbPath) {
- do {
- database = try Database(path: dbPath)
- checks.append(("database opens", true, dbPath))
- } catch {
- checks.append(("database opens", false, "\(error)"))
- }
- } else {
- checks.append(("database opens", false, "missing: \(dbPath)"))
- }
-
- // 3. Required tables exist.
- let requiredTables = ["entries", "entries_fts", "config", "stats_counters"]
- if let database {
- let found = (try? database.scalarInt64(
- "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('entries','entries_fts','config','stats_counters')"
- )) ?? 0
- let ok = found == Int64(requiredTables.count)
- checks.append(("required tables", ok,
- ok ? requiredTables.joined(separator: ", ") : "found \(found) of \(requiredTables.count)"))
- } else {
- checks.append(("required tables", false, "database unavailable"))
- }
-
- // 4. Indexes exist.
- let requiredIndexes = ["idx_entries_hash", "idx_entries_lru", "idx_entries_type"]
- if let database {
- let found = (try? database.scalarInt64(
- "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN ('idx_entries_hash','idx_entries_lru','idx_entries_type')"
- )) ?? 0
- let ok = found == Int64(requiredIndexes.count)
- checks.append(("indexes", ok, ok ? requiredIndexes.joined(separator: ", ")
- : "found \(found) of \(requiredIndexes.count)"))
- } else {
- checks.append(("indexes", false, "database unavailable"))
- }
-
- // 5. FTS5 available.
- let ftsAvailable: Bool = {
- guard let probe = try? Database(path: ":memory:") else { return false }
- return (try? probe.exec("CREATE VIRTUAL TABLE fts_probe USING fts5(x)")) != nil
- }()
- checks.append(("FTS5 available", ftsAvailable,
- ftsAvailable ? "fts5 module present" : "fts5 module missing"))
-
- // 6. Images dir writable.
- let imagesPath = dir.appendingPathComponent("images", isDirectory: true).path
- var imagesIsDir: ObjCBool = false
- let imagesOK = fm.fileExists(atPath: imagesPath, isDirectory: &imagesIsDir)
- && imagesIsDir.boolValue && fm.isWritableFile(atPath: imagesPath)
- checks.append(("images directory", imagesOK, imagesOK ? imagesPath : "missing or not writable: \(imagesPath)"))
+ private static let tagsSubquery =
+ "(SELECT GROUP_CONCAT(tag, '\(CoreConstants.tagConcatSeparator)') FROM entry_tags WHERE entry_id = entries.id) AS tags"
- // 7. Disk free space > 200MB.
- let probeURL = dirExists ? dir : fm.homeDirectoryForCurrentUser
- if let values = try? probeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
- let free = values.volumeAvailableCapacityForImportantUsage {
- let ok = free > 200 * 1024 * 1024
- checks.append(("disk space", ok, "\(ByteSize.format(free)) free"))
- } else {
- checks.append(("disk space", false, "unable to determine free space"))
- }
-
- // 8. Shell history file.
- let shellHistfile: URL? = {
- if let custom = try? database?.scalarText("SELECT value FROM config WHERE key = 'shell.histfile'"),
- !custom.trimmingCharacters(in: .whitespaces).isEmpty {
- return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath)
- }
- return ShellHistoryParser.defaultHistoryFile()
- }()
- if let shellHistfile {
- let readable = fm.isReadableFile(atPath: shellHistfile.path)
- checks.append(("shell history", readable, readable ? shellHistfile.path : "not readable: \(shellHistfile.path)"))
- } else {
- checks.append(("shell history", true, "no ~/.zsh_history or ~/.bash_history found (auto-detect)"))
- }
+ static let entryColumns: String =
+ (entryColumnNames + [tagsSubquery]).joined(separator: ", ")
- return checks
+ /// entryColumns with every column prefixed for joined queries (FTS search).
+ static var prefixedEntryColumns: String {
+ let prefixed = entryColumnNames.map { "e.\($0)" }
+ let tagsPrefixed = tagsSubquery.replacingOccurrences(of: "entries.id", with: "e.id")
+ return (prefixed + [tagsPrefixed]).joined(separator: ", ")
}
- // MARK: - Internal helpers
-
- static let entryColumns = "id, type, content, image_path, image_format, content_hash, created_at, last_used_at, size_bytes, is_pinned, is_favorite, use_count, source_app, shortcut"
-
static func rowToEntry(_ stmt: Statement) -> ClipboardEntry {
- ClipboardEntry(
+ let tagStr = stmt.text(14)
+ let tags = tagStr?.components(separatedBy: CoreConstants.tagConcatSeparator)
+ .filter { !$0.isEmpty } ?? []
+ return ClipboardEntry(
id: stmt.int64(0),
type: EntryType(rawValue: stmt.text(1) ?? "") ?? .text,
content: stmt.text(2),
@@ -857,219 +107,106 @@ public actor ClipboardStore {
isFavorite: stmt.int64(10) != 0,
useCount: Int(stmt.int64(11)),
sourceApp: stmt.text(12),
- shortcut: stmt.text(13)
+ shortcut: stmt.text(13),
+ tags: tags
)
}
- private func firstEntry(_ whereClause: String, _ binds: [SQLValue]) throws -> ClipboardEntry? {
+ func firstEntry(_ whereClause: String, _ binds: [SQLValue]) throws -> ClipboardEntry? {
try db.query("SELECT \(Self.entryColumns) FROM entries WHERE \(whereClause) LIMIT 1",
binds, Self.rowToEntry).first
}
- private func incrementCounter(_ key: String) throws {
+ func incrementCounter(_ key: String) throws {
try db.run("""
INSERT INTO stats_counters (key, value) VALUES (?, 1)
ON CONFLICT(key) DO UPDATE SET value = value + 1
""", [.text(key)])
}
- /// yyyy-MM-dd for the current day (local calendar).
- static func dayKey(_ date: Date = Date()) -> String {
- let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
- return String(format: "%04d-%02d-%02d", c.year ?? 1970, c.month ?? 1, c.day ?? 1)
+ static let touchSQL = "UPDATE entries SET last_used_at = ?, use_count = use_count + 1 WHERE id = ?"
+
+ func touchRow(id: Int64, at timestamp: Double) throws {
+ try db.run(Self.touchSQL, [.double(timestamp), .int(id)])
}
- private func bumpDailyCounter(_ keyPrefix: String) throws {
- try incrementCounter("\(keyPrefix)_\(Self.dayKey())")
+ static let insertEntrySQL = """
+ INSERT INTO entries (type, content, image_path, image_format, content_hash,
+ created_at, last_used_at, size_bytes, is_pinned, use_count, source_app)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """
+
+ func insertEntry(type: EntryType, content: String?, imagePath: String?, imageFormat: String?,
+ hash: String, createdAt: Double, lastUsedAt: Double, sizeBytes: Int64,
+ pinned: Bool, useCount: Int, sourceApp: String?) throws -> Int64 {
+ try db.run(Self.insertEntrySQL, [
+ .text(type.rawValue),
+ content.map(SQLValue.text) ?? .null,
+ imagePath.map(SQLValue.text) ?? .null,
+ imageFormat.map(SQLValue.text) ?? .null,
+ .text(hash),
+ .double(createdAt), .double(lastUsedAt),
+ .int(sizeBytes), .int(pinned ? 1 : 0), .int(Int64(max(1, useCount))),
+ sourceApp.map(SQLValue.text) ?? .null
+ ])
+ return db.lastInsertRowid
}
- private func runRetentionCleanup() {
- let maxAgeDays = (try? configInt("retention.days")) ?? 0
- guard maxAgeDays > 0 else { return }
- let cutoff = Date().addingTimeInterval(-Double(maxAgeDays) * 86_400)
- let entries = try? db.query("SELECT \(Self.entryColumns) FROM entries WHERE created_at < ? AND is_pinned = 0 AND is_favorite = 0",
- [.double(cutoff.timeIntervalSince1970)], Self.rowToEntry)
- if let entries, !entries.isEmpty {
- try? db.transaction {
- try deleteRowsInCurrentTransaction(entries)
- removeFiles(for: entries)
- }
- }
+ /// yyyy-MM-dd for the given day (local calendar).
+ static func dayKey(_ date: Date) -> String {
+ let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", c.year ?? 1970, c.month ?? 1, c.day ?? 1)
+ }
+
+ /// Strips whitespace and leading '#', lowercases. Returns nil when empty
+ /// or when the tag contains the GROUP_CONCAT separator (would corrupt
+ /// row mapping).
+ static func normalizeTag(_ rawTag: String) -> String? {
+ let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "#"))
+ .lowercased()
+ guard !tag.isEmpty, !tag.contains(CoreConstants.tagConcatSeparator) else { return nil }
+ return tag
}
- private func configInt(_ key: String) throws -> Int {
+ func configInt(_ key: String) throws -> Int {
if let raw = try config(key), let value = Int(raw) { return value }
return Int(Self.configDefaults[key] ?? "0") ?? 0
}
- private func configInt64(_ key: String) throws -> Int64 {
+ func configInt64(_ key: String) throws -> Int64 {
if let raw = try config(key), let value = Int64(raw) { return value }
return Int64(Self.configDefaults[key] ?? "0") ?? 0
}
/// Deletes rows by id. Must already be inside a transaction.
- private func deleteRowsInCurrentTransaction(_ entries: [ClipboardEntry]) throws {
+ func deleteRowsInCurrentTransaction(_ entries: [ClipboardEntry]) throws {
guard !entries.isEmpty else { return }
- for chunk in entries.map(\.id).chunked(500) {
+ for chunk in entries.map(\.id).chunked(CoreConstants.sqlBatchSize) {
let placeholders = Array(repeating: "?", count: chunk.count).joined(separator: ",")
try db.run("DELETE FROM entries WHERE id IN (\(placeholders))", chunk.map(SQLValue.int))
}
}
/// Removes image + thumbnail files for evicted/deleted image entries.
- private func removeFiles(for entries: [ClipboardEntry]) {
+ /// Failures are logged (metadata only): silent data retention here would
+ /// defeat byte-budget eviction.
+ func removeFiles(for entries: [ClipboardEntry]) {
let fm = FileManager.default
- for entry in entries where entry.type == .image {
- if let path = entry.imagePath {
- try? fm.removeItem(at: imagesDir.appendingPathComponent(path))
- }
- try? fm.removeItem(at: thumbnailsDir.appendingPathComponent("\(entry.contentHash).png"))
- }
- }
-
- // MARK: - Search internals
-
- /// `type IN (…)` fragment + binds for the query's effective type filter.
- private static func typeFilter(_ query: SearchQuery,
- column: String = "type") -> (sql: String, binds: [SQLValue])? {
- guard let types = query.effectiveTypes, !types.isEmpty else { return nil }
- let placeholders = Array(repeating: "?", count: types.count).joined(separator: ", ")
- let binds = types.map { SQLValue.text($0.rawValue) }.sorted { lhs, rhs in
- if case let .text(l) = lhs, case let .text(r) = rhs { return l < r }
- return false
- }
- return ("\(column) IN (\(placeholders))", binds)
- }
-
- private func filteredList(_ query: SearchQuery) throws -> [ClipboardEntry] {
- var conditions: [String] = []
- var binds: [SQLValue] = []
- if let filter = Self.typeFilter(query) {
- conditions.append(filter.sql)
- binds.append(contentsOf: filter.binds)
- }
- if query.pinnedOnly { conditions.append("is_pinned = 1") }
- if query.favoriteOnly { conditions.append("is_favorite = 1") }
- var sql = "SELECT \(Self.entryColumns) FROM entries"
- if !conditions.isEmpty { sql += " WHERE " + conditions.joined(separator: " AND ") }
- sql += " ORDER BY last_used_at DESC, id DESC LIMIT ? OFFSET ?"
- binds.append(.int(Int64(max(0, query.limit))))
- binds.append(.int(Int64(max(0, query.offset))))
- return try db.query(sql, binds, Self.rowToEntry)
- }
-
- /// Builds an FTS5 MATCH expression: bare terms become `"term"*` (prefix)
- /// AND-joined; quoted phrases stay phrases. Double quotes are escaped.
- static func ftsMatchExpression(_ tokens: [QueryTokenizer.Token]) -> String {
- tokens.map { token in
- let escaped = token.value.replacingOccurrences(of: "\"", with: "\"\"")
- return token.quoted ? "\"\(escaped)\"" : "\"\(escaped)\"*"
- }.joined(separator: " ")
- }
-
- private func ftsSearch(_ tokens: [QueryTokenizer.Token], query: SearchQuery) throws -> [ClipboardEntry] {
- let match = Self.ftsMatchExpression(tokens)
- let prefixedColumns = Self.entryColumns
- .split(separator: ",")
- .map { "e.\($0.trimmingCharacters(in: .whitespaces))" }
- .joined(separator: ", ")
- var sql = """
- SELECT \(prefixedColumns) FROM entries e
- JOIN entries_fts ON entries_fts.rowid = e.id
- WHERE entries_fts MATCH ?
- """
- var binds: [SQLValue] = [.text(match)]
- if let filter = Self.typeFilter(query, column: "e.type") {
- sql += " AND \(filter.sql)"
- binds.append(contentsOf: filter.binds)
- }
- if query.pinnedOnly { sql += " AND e.is_pinned = 1" }
- if query.favoriteOnly { sql += " AND e.is_favorite = 1" }
- sql += " ORDER BY e.last_used_at DESC, e.id DESC LIMIT ? OFFSET ?"
- binds.append(.int(Int64(max(0, query.limit))))
- binds.append(.int(Int64(max(0, query.offset))))
- return try db.query(sql, binds, Self.rowToEntry)
- }
-
- static let regexScanCap = 20_000
- static let regexScanBatchSize = 500
- /// Wall-clock budget for a whole regex scan. Length caps alone don't
- /// bound catastrophic backtracking; without this a pathological pattern
- /// could stall the store actor (and with it, clipboard capture).
- static let regexScanTimeBudget: TimeInterval = 2.0
-
- /// Batched candidate scan over text entries in last_used_at DESC order.
- /// Calls `visit` per row; stops when `visit` returns false, the scan cap
- /// is reached, or the time budget is exhausted (partial results).
- private func scanTextEntries(pinnedOnly: Bool, favoriteOnly: Bool = false, contentType: EntryType? = nil,
- _ visit: (ClipboardEntry) throws -> Bool) throws {
- var scanned = 0
- var dbOffset = 0
- let deadline = Date().addingTimeInterval(Self.regexScanTimeBudget)
- while scanned < Self.regexScanCap {
- if Date() >= deadline { return }
- // Regex scans every content-bearing type (text + shell).
- var sql = "SELECT \(Self.entryColumns) FROM entries WHERE type IN ('text', 'shell')"
- if let only = contentType { sql += " AND type = '\(only.rawValue)'" }
- if pinnedOnly { sql += " AND is_pinned = 1" }
- if favoriteOnly { sql += " AND is_favorite = 1" }
- sql += " ORDER BY last_used_at DESC, id DESC LIMIT ? OFFSET ?"
- let batch = try db.query(sql, [.int(Int64(Self.regexScanBatchSize)), .int(Int64(dbOffset))],
- Self.rowToEntry)
- if batch.isEmpty { return }
- for entry in batch {
- scanned += 1
- if try !visit(entry) { return }
- if scanned >= Self.regexScanCap { return }
- if Date() >= deadline { return }
+ func remove(_ url: URL, label: String) {
+ do {
+ try fm.removeItem(at: url)
+ } catch let error as CocoaError where error.code == .fileNoSuchFile {
+ return
+ } catch {
+ Self.logger.error("\(label, privacy: .public) delete failed: \(error.localizedDescription, privacy: .public)")
}
- dbOffset += batch.count
- if batch.count < Self.regexScanBatchSize { return }
}
- }
-
- private func regexSearch(_ pattern: String, query: SearchQuery) throws -> [ClipboardEntry] {
- // Regex applies to content-bearing entries only (text and shell).
- let allowed = (query.effectiveTypes ?? [.text, .shell]).subtracting([.image])
- if allowed.isEmpty { return [] }
- let single = allowed.count == 1 ? allowed.first : nil
- let regex = try SafeRegex.compile(pattern)
- var results: [ClipboardEntry] = []
- var toSkip = max(0, query.offset)
- let limit = max(0, query.limit)
- guard limit > 0 else { return [] }
- try scanTextEntries(pinnedOnly: query.pinnedOnly, favoriteOnly: query.favoriteOnly, contentType: single) { entry in
- if let content = entry.content, SafeRegex.matches(regex, in: content) {
- if toSkip > 0 {
- toSkip -= 1
- } else {
- results.append(entry)
- if results.count >= limit { return false }
- }
+ for entry in entries where entry.type == .image {
+ if let path = entry.imagePath {
+ remove(imagesDir.appendingPathComponent(path), label: "image")
}
- return true
- }
- return results
- }
-
- // MARK: - Test hooks (internal)
-
- /// Backdates timestamps for deterministic ordering/retention tests.
- func _test_setTimestamps(id: Int64, createdAt: Date? = nil, lastUsedAt: Date? = nil) throws {
- if let createdAt {
- try db.run("UPDATE entries SET created_at = ? WHERE id = ?",
- [.double(createdAt.timeIntervalSince1970), .int(id)])
- }
- if let lastUsedAt {
- try db.run("UPDATE entries SET last_used_at = ? WHERE id = ?",
- [.double(lastUsedAt.timeIntervalSince1970), .int(id)])
+ remove(thumbnailsDir.appendingPathComponent("\(entry.contentHash).png"), label: "thumbnail")
}
}
}
-
-extension Array {
- func chunked(_ size: Int) -> [[Element]] {
- guard size > 0 else { return [self] }
- return stride(from: 0, to: count, by: size).map { Array(self[$0.. String {
+ guard !s.isEmpty else { return "" }
+ let maxScan = max(maxChars * 4, 1000)
+ let prefixSlice = s.count > maxScan ? s.prefix(maxScan) : s[...]
+ var collapsed = ""
+ var previousWasSpace = true
+ var nonSpaceCount = 0
+ for scalar in prefixSlice.unicodeScalars {
+ if scalar.properties.isWhitespace || scalar.value < 0x20 || scalar.value == 0x7f {
+ if !previousWasSpace {
+ collapsed.unicodeScalars.append(" ")
+ previousWasSpace = true
+ }
+ } else {
+ collapsed.unicodeScalars.append(scalar)
+ previousWasSpace = false
+ nonSpaceCount += 1
+ if nonSpaceCount >= maxChars + 1 {
+ break
+ }
+ }
+ }
+ let trimmed = collapsed.trimmingCharacters(in: .whitespaces)
+ if s.count > maxChars || trimmed.count > maxChars {
+ let cutoff = trimmed.index(trimmed.startIndex, offsetBy: min(trimmed.count, maxChars))
+ return String(trimmed[.. String {
+ let interval = now.timeIntervalSince(date)
+ let elapsed = interval >= 0 ? interval : -interval
+ let suffix = interval >= 0 ? "" : "?"
+ if elapsed < 60 { return "now" }
+ if elapsed < 3600 { return "\(Int(elapsed / 60))m\(suffix)" }
+ if elapsed < 86_400 { return "\(Int(elapsed / 3600))h\(suffix)" }
+ if elapsed < 7 * 86_400 { return "\(Int(elapsed / 86_400))d\(suffix)" }
+ if elapsed < 30 * 86_400 { return "\(max(1, Int(elapsed / (7 * 86_400))))w\(suffix)" }
+ if elapsed < 365 * 86_400 { return "\(max(1, Int(elapsed / (30 * 86_400))))mo\(suffix)" }
+ return "\(max(1, Int(elapsed / (365 * 86_400))))y\(suffix)"
+ }
+}
+
+public enum ImageFormats {
+ private static let explicit: [String: String] = [
+ "png": "public.png",
+ "jpeg": "public.jpeg",
+ "jpg": "public.jpeg",
+ "tiff": "public.tiff",
+ "tif": "public.tiff",
+ "gif": "com.compuserve.gif",
+ "bmp": "com.microsoft.bmp",
+ "webp": "org.webpproject.webp",
+ "heic": "public.heic"
+ ]
+
+ /// Maps a stored image format ("png", "jpeg", ...) to its UTI identifier.
+ /// Returns nil for unknown formats so callers can apply their own
+ /// fallback (e.g. TIFF conversion).
+ public static func uti(forFormat format: String) -> String? {
+ explicit[format.lowercased()]
+ }
+}
public enum TextNormalizer {
/// Trims leading/trailing whitespace and newlines. Interior whitespace
@@ -37,7 +107,7 @@ public enum ByteSize {
// Order matters: longest suffixes first.
let suffixes: [(String, Int64)] = [
("gb", 1 << 30), ("mb", 1 << 20), ("kb", 1 << 10),
- ("g", 1 << 30), ("m", 1 << 20), ("k", 1 << 10), ("b", 1),
+ ("g", 1 << 30), ("m", 1 << 20), ("k", 1 << 10), ("b", 1)
]
var numberPart = trimmed
var multiplier: Int64 = 1
@@ -98,3 +168,10 @@ public enum SafeRegex {
return regex.firstMatch(in: slice, options: [], range: range) != nil
}
}
+
+extension Array {
+ func chunked(_ size: Int) -> [[Element]] {
+ guard size > 0 else { return [self] }
+ return stride(from: 0, to: count, by: size).map { Array(self[$0.. CLI IPC.
+/// Contract: ARCHITECTURE.md "IPC" section.
+public enum IPCNotifications {
+ public static let openUI = "com.spongycode.clap.openUI"
+ public static let storeChanged = "com.spongycode.clap.storeChanged"
+ public static let configChanged = "com.spongycode.clap.configChanged"
+}
diff --git a/Sources/ClapCore/Models.swift b/Sources/ClapCore/Models.swift
index 08b5f34..d193d7f 100644
--- a/Sources/ClapCore/Models.swift
+++ b/Sources/ClapCore/Models.swift
@@ -17,10 +17,12 @@ public struct ClipboardEntry: Identifiable, Sendable, Equatable {
public let useCount: Int
public let sourceApp: String?
public let shortcut: String?
+ public let tags: [String]
public init(id: Int64, type: EntryType, content: String?, imagePath: String?, imageFormat: String?,
contentHash: String, createdAt: Date, lastUsedAt: Date, sizeBytes: Int64,
- isPinned: Bool, isFavorite: Bool, useCount: Int, sourceApp: String?, shortcut: String? = nil) {
+ isPinned: Bool, isFavorite: Bool, useCount: Int, sourceApp: String?, shortcut: String? = nil,
+ tags: [String] = []) {
self.id = id
self.type = type
self.content = content
@@ -35,6 +37,7 @@ public struct ClipboardEntry: Identifiable, Sendable, Equatable {
self.useCount = useCount
self.sourceApp = sourceApp
self.shortcut = shortcut
+ self.tags = tags
}
}
@@ -47,18 +50,21 @@ public struct SearchQuery: Sendable {
public var types: Set?
public var pinnedOnly: Bool
public var favoriteOnly: Bool
+ public var tag: String? // filter by specific tag
public var limit: Int
public var offset: Int
public init(text: String? = nil, regex: String? = nil, type: EntryType? = nil,
types: Set? = nil,
- pinnedOnly: Bool = false, favoriteOnly: Bool = false, limit: Int = 100, offset: Int = 0) {
+ pinnedOnly: Bool = false, favoriteOnly: Bool = false, tag: String? = nil,
+ limit: Int = 100, offset: Int = 0) {
self.text = text
self.regex = regex
self.type = type
self.types = types
self.pinnedOnly = pinnedOnly
self.favoriteOnly = favoriteOnly
+ self.tag = tag
self.limit = limit
self.offset = offset
}
@@ -70,12 +76,13 @@ public struct SearchQuery: Sendable {
}
/// Parses UI/CLI query syntax: bare terms, "quoted phrase",
- /// `regex:`, `type:text|image`. Unknown filters ignored.
+ /// `regex:`, `type:text|image`, `tag:`. Unknown filters ignored.
/// If both regex and text are present, regex wins and text is ignored.
public static func parse(_ raw: String, limit: Int, offset: Int) -> SearchQuery {
let tokens = QueryTokenizer.tokenize(raw)
var type: EntryType?
var regex: String?
+ var tag: String?
var textTokens: [QueryTokenizer.Token] = []
for token in tokens {
@@ -88,6 +95,11 @@ public struct SearchQuery: Sendable {
}
continue
}
+ if !token.quoted, token.value.lowercased().hasPrefix("tag:") {
+ let tagVal = String(token.value.dropFirst(4)).trimmingCharacters(in: .whitespaces)
+ if !tagVal.isEmpty { tag = tagVal }
+ continue
+ }
if !token.quoted, token.value.lowercased().hasPrefix("regex:") {
if regex == nil { regex = String(token.value.dropFirst(6)) }
continue
@@ -112,7 +124,7 @@ public struct SearchQuery: Sendable {
.joined(separator: " ")
}
return SearchQuery(text: text, regex: regex, type: type,
- pinnedOnly: false, limit: limit, offset: offset)
+ pinnedOnly: false, tag: tag, limit: limit, offset: offset)
}
}
diff --git a/Sources/ClapCore/OCREngine.swift b/Sources/ClapCore/OCREngine.swift
new file mode 100644
index 0000000..1dd1921
--- /dev/null
+++ b/Sources/ClapCore/OCREngine.swift
@@ -0,0 +1,44 @@
+import Foundation
+import Vision
+import os
+
+/// Injectable OCR seam so capture paths can be tested without Vision.
+public protocol OCREngine: Sendable {
+ func recognizeText(from imageData: Data) async -> String?
+}
+
+/// Apple Vision text recognition (accurate mode, no language correction).
+/// The blocking Vision call runs on a utility queue so awaiting it never
+/// blocks the store actor.
+public struct VisionOCREngine: OCREngine {
+ private static let logger = Logger(subsystem: ClapIdentity.bundleID, category: "ocr")
+
+ public init() {}
+
+ public func recognizeText(from imageData: Data) async -> String? {
+ await withCheckedContinuation { continuation in
+ DispatchQueue.global(qos: .utility).async {
+ continuation.resume(returning: Self.performOCR(imageData))
+ }
+ }
+ }
+
+ private static func performOCR(_ imageData: Data) -> String? {
+ let request = VNRecognizeTextRequest()
+ request.recognitionLevel = .accurate
+ request.usesLanguageCorrection = false
+ let handler = VNImageRequestHandler(data: imageData, options: [:])
+ do {
+ try handler.perform([request])
+ guard let observations = request.results else { return nil }
+ let lines = observations
+ .compactMap { $0.topCandidates(1).first?.string.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ guard !lines.isEmpty else { return nil }
+ return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ } catch {
+ logger.error("OCR failed: \(error.localizedDescription, privacy: .public)")
+ return nil
+ }
+ }
+}
diff --git a/Sources/ClapCore/OCRScanner.swift b/Sources/ClapCore/OCRScanner.swift
deleted file mode 100644
index da69a76..0000000
--- a/Sources/ClapCore/OCRScanner.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import Foundation
-import Vision
-
-/// Extracts text from images using Apple's built-in Vision framework (`VNRecognizeTextRequest`).
-public enum OCRScanner {
- /// Extracts text lines from an image file URL.
- /// Returns multi-line text string or `nil` if no text is recognized.
- public static func recognizeText(from imageURL: URL) -> String? {
- let request = VNRecognizeTextRequest()
- request.recognitionLevel = .accurate
- request.usesLanguageCorrection = false
-
- let handler = VNImageRequestHandler(url: imageURL, options: [:])
- do {
- try handler.perform([request])
- guard let observations = request.results, !observations.isEmpty else {
- return nil
- }
- let lines = observations
- .compactMap { $0.topCandidates(1).first?.string.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- guard !lines.isEmpty else { return nil }
- let text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
- return text.isEmpty ? nil : text
- } catch {
- return nil
- }
- }
-
- /// Extracts text lines from raw image data (e.g. PNG / TIFF / JPEG).
- public static func recognizeText(from data: Data) -> String? {
- let request = VNRecognizeTextRequest()
- request.recognitionLevel = .accurate
- request.usesLanguageCorrection = false
-
- let handler = VNImageRequestHandler(data: data, options: [:])
- do {
- try handler.perform([request])
- guard let observations = request.results, !observations.isEmpty else {
- return nil
- }
- let lines = observations
- .compactMap { $0.topCandidates(1).first?.string.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- guard !lines.isEmpty else { return nil }
- let text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
- return text.isEmpty ? nil : text
- } catch {
- return nil
- }
- }
-}
diff --git a/Sources/ClapCore/TextAnalysis.swift b/Sources/ClapCore/TextAnalysis.swift
new file mode 100644
index 0000000..a697962
--- /dev/null
+++ b/Sources/ClapCore/TextAnalysis.swift
@@ -0,0 +1,426 @@
+import Foundation
+
+// MARK: - Clipboard content analysis shared by the app and CLI:
+// color codes, case conversion, Base64/URL transforms, JWT and epoch parsing.
+
+/// A parsed color code as normalized components (no AppKit dependency).
+public struct ParsedColor: Equatable, Sendable {
+ public let red: Double
+ public let green: Double
+ public let blue: Double
+ public let alpha: Double
+
+ public init(red: Double, green: Double, blue: Double, alpha: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ self.alpha = alpha
+ }
+}
+
+public enum ColorParser {
+ /// Parses hex (#RGB/#RGBA/#RRGGBB/#RRGGBBAA/0xRRGGBB), rgb()/rgba() and
+ /// hsl()/hsla() strings. Returns nil for anything else.
+ public static func parse(_ raw: String?) -> ParsedColor? {
+ guard let raw else { return nil }
+ let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard text.count >= 4 && text.count <= 40 else { return nil }
+
+ // Hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA, 0xRRGGBB
+ if text.hasPrefix("#") || text.hasPrefix("0x") {
+ let hex = text.hasPrefix("#") ? String(text.dropFirst()) : String(text.dropFirst(2))
+ guard let intVal = UInt64(hex, radix: 16) else { return nil }
+ switch hex.count {
+ case 3: // RGB
+ return ParsedColor(
+ red: Double((intVal >> 8) & 0xF) / 15.0,
+ green: Double((intVal >> 4) & 0xF) / 15.0,
+ blue: Double(intVal & 0xF) / 15.0,
+ alpha: 1.0)
+ case 4: // RGBA
+ return ParsedColor(
+ red: Double((intVal >> 12) & 0xF) / 15.0,
+ green: Double((intVal >> 8) & 0xF) / 15.0,
+ blue: Double((intVal >> 4) & 0xF) / 15.0,
+ alpha: Double(intVal & 0xF) / 15.0)
+ case 6: // RRGGBB
+ return ParsedColor(
+ red: Double((intVal >> 16) & 0xFF) / 255.0,
+ green: Double((intVal >> 8) & 0xFF) / 255.0,
+ blue: Double(intVal & 0xFF) / 255.0,
+ alpha: 1.0)
+ case 8: // RRGGBBAA
+ return ParsedColor(
+ red: Double((intVal >> 24) & 0xFF) / 255.0,
+ green: Double((intVal >> 16) & 0xFF) / 255.0,
+ blue: Double((intVal >> 8) & 0xFF) / 255.0,
+ alpha: Double(intVal & 0xFF) / 255.0)
+ default:
+ return nil
+ }
+ }
+
+ let lower = text.lowercased()
+ // rgb(...) or rgba(...)
+ if lower.hasPrefix("rgb(") || lower.hasPrefix("rgba(") {
+ let inner = lower.replacingOccurrences(of: "rgba(", with: "")
+ .replacingOccurrences(of: "rgb(", with: "")
+ .replacingOccurrences(of: ")", with: "")
+ let parts = splitComponents(inner)
+ if parts.count >= 3, let r = Double(parts[0]),
+ let g = Double(parts[1]), let b = Double(parts[2]) {
+ let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
+ return ParsedColor(
+ red: max(0, min(255, r)) / 255.0,
+ green: max(0, min(255, g)) / 255.0,
+ blue: max(0, min(255, b)) / 255.0,
+ alpha: max(0, min(1.0, a)))
+ }
+ }
+
+ // hsl(...) or hsla(...)
+ if lower.hasPrefix("hsl(") || lower.hasPrefix("hsla(") {
+ let inner = lower.replacingOccurrences(of: "hsla(", with: "")
+ .replacingOccurrences(of: "hsl(", with: "")
+ .replacingOccurrences(of: ")", with: "")
+ .replacingOccurrences(of: "%", with: "")
+ let parts = splitComponents(inner)
+ if parts.count >= 3, let h = Double(parts[0]),
+ let s = Double(parts[1]), let l = Double(parts[2]) {
+ let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
+ let rgb = hslToRgb(
+ hueDegrees: h,
+ saturationPercent: s,
+ lightnessPercent: l,
+ alpha: a)
+ return ParsedColor(red: rgb.red, green: rgb.green, blue: rgb.blue, alpha: rgb.alpha)
+ }
+ }
+
+ return nil
+ }
+
+ /// Standard CSS HSL → RGB conversion (Foley/van Dam). Inputs are clamped:
+ /// hue wraps modulo 360, saturation/lightness clamp to percent ranges.
+ static func hslToRgb(hueDegrees: Double, saturationPercent: Double,
+ lightnessPercent: Double, alpha: Double) -> ParsedColor {
+ let h = (hueDegrees.truncatingRemainder(dividingBy: 360) + 360)
+ .truncatingRemainder(dividingBy: 360) / 360.0
+ let s = max(0, min(100, saturationPercent)) / 100.0
+ let l = max(0, min(100, lightnessPercent)) / 100.0
+
+ let chroma = (1 - abs(2 * l - 1)) * s
+ let hueSector = h * 6
+ let secondary = chroma * (1 - abs(hueSector.truncatingRemainder(dividingBy: 2) - 1))
+ let (r1, g1, b1): (Double, Double, Double)
+ switch hueSector {
+ case ..<1: (r1, g1, b1) = (chroma, secondary, 0)
+ case ..<2: (r1, g1, b1) = (secondary, chroma, 0)
+ case ..<3: (r1, g1, b1) = (0, chroma, secondary)
+ case ..<4: (r1, g1, b1) = (0, secondary, chroma)
+ case ..<5: (r1, g1, b1) = (secondary, 0, chroma)
+ default: (r1, g1, b1) = (chroma, 0, secondary)
+ }
+ let match = l - chroma / 2
+ return ParsedColor(
+ red: r1 + match,
+ green: g1 + match,
+ blue: b1 + match,
+ alpha: max(0, min(1.0, alpha)))
+ }
+
+ private static func splitComponents(_ inner: String) -> [String] {
+ inner.split(whereSeparator: { $0 == "," || $0 == " " || $0 == "/" })
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ }
+}
+
+public enum CaseConverter {
+ public enum CaseStyle: String, CaseIterable, Identifiable, Sendable {
+ case camelCase = "camelCase"
+ case pascalCase = "PascalCase"
+ case snakeCase = "snake_case"
+ case kebabCase = "kebab-case"
+ case constantCase = "CONSTANT_CASE"
+ case uppercase = "UPPERCASE"
+ case lowercase = "lowercase"
+ case titleCase = "Title Case"
+
+ public var id: String { rawValue }
+ }
+
+ public static func convert(_ text: String, to style: CaseStyle) -> String {
+ let words = splitWords(text)
+ guard !words.isEmpty else {
+ switch style {
+ case .uppercase: return text.uppercased()
+ case .lowercase: return text.lowercased()
+ default: return text
+ }
+ }
+
+ switch style {
+ case .camelCase:
+ let first = words[0].lowercased()
+ let rest = words.dropFirst().map { $0.capitalized }
+ return ([first] + rest).joined()
+
+ case .pascalCase:
+ return words.map { $0.capitalized }.joined()
+
+ case .snakeCase:
+ return words.map { $0.lowercased() }.joined(separator: "_")
+
+ case .kebabCase:
+ return words.map { $0.lowercased() }.joined(separator: "-")
+
+ case .constantCase:
+ return words.map { $0.uppercased() }.joined(separator: "_")
+
+ case .uppercase:
+ return text.uppercased()
+
+ case .lowercase:
+ return text.lowercased()
+
+ case .titleCase:
+ return words.map { $0.capitalized }.joined(separator: " ")
+ }
+ }
+
+ private static func splitWords(_ text: String) -> [String] {
+ var words: [String] = []
+ var current = ""
+
+ func flush() {
+ if !current.isEmpty {
+ words.append(current)
+ current = ""
+ }
+ }
+
+ let chars = Array(text)
+ for i in 0.. 0 && chars[i-1].isLowercase)
+ let nextIsLower = (i + 1 < chars.count && chars[i+1].isLowercase && current.count > 1)
+ if prevIsLower || nextIsLower {
+ flush()
+ }
+ }
+ current.append(ch)
+ } else if ch.isNumber {
+ let prevIsLetter = (i > 0 && chars[i-1].isLetter)
+ if prevIsLetter {
+ flush()
+ }
+ current.append(ch)
+ } else {
+ flush()
+ }
+ }
+ flush()
+ return words
+ }
+}
+
+public enum TextTransformer {
+ public static let maxTransformLength = 10_000
+
+ public static func decodeBase64(_ text: String) -> String? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.count >= 4, trimmed.count <= maxTransformLength else { return nil }
+ let pattern = "^[A-Za-z0-9+/]+={0,2}$"
+ guard trimmed.range(of: pattern, options: .regularExpression) != nil else { return nil }
+ guard let data = Data(base64Encoded: trimmed),
+ let decoded = String(data: data, encoding: .utf8),
+ !decoded.isEmpty,
+ decoded != trimmed,
+ decoded.allSatisfy({ !$0.isASCII || $0.isWhitespace || $0.isLetter
+ || $0.isNumber || $0.isPunctuation || $0.isSymbol }) else {
+ return nil
+ }
+ return decoded
+ }
+
+ public static func encodeBase64(_ text: String) -> String {
+ Data(text.utf8).base64EncodedString()
+ }
+
+ public static func decodeURL(_ text: String) -> String? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.contains("%"), trimmed.count <= maxTransformLength else { return nil }
+ guard let decoded = trimmed.removingPercentEncoding, decoded != trimmed else { return nil }
+ return decoded
+ }
+
+ public static func encodeURL(_ text: String) -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? text
+ }
+}
+
+/// Decoded JWT parts. Only the pre-rendered JSON strings are stored so the
+/// struct stays Sendable.
+public struct JWTData: Sendable, Equatable {
+ public let headerJSON: String
+ public let payloadJSON: String
+ public let algorithm: String
+ public let isExpired: Bool?
+ public let expirationDate: Date?
+ public let issuedAtDate: Date?
+ public let subject: String?
+ public let issuer: String?
+
+ public static func parse(_ text: String?) -> JWTData? {
+ guard let text else { return nil }
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.count >= 20, trimmed.count <= 20_000 else { return nil }
+ let parts = trimmed.components(separatedBy: ".")
+ guard parts.count == 3 else { return nil }
+
+ guard let headerObj = decodeBase64URLJSON(parts[0]),
+ let payloadObj = decodeBase64URLJSON(parts[1]) else {
+ return nil
+ }
+
+ let alg = (headerObj["alg"] as? String) ?? "Unknown"
+ let typ = (headerObj["typ"] as? String)?.uppercased()
+ guard headerObj["alg"] != nil || typ == "JWT" else {
+ return nil
+ }
+
+ let headerStr = prettyJSON(headerObj)
+ let payloadStr = prettyJSON(payloadObj)
+
+ func epochDate(_ key: String) -> Date? {
+ guard let value = payloadObj[key] else { return nil }
+ let seconds: Double?
+ if let num = value as? Double {
+ seconds = num
+ } else if let int = value as? Int64 {
+ seconds = Double(int)
+ } else if let int = value as? Int {
+ seconds = Double(int)
+ } else {
+ seconds = nil
+ }
+ return seconds.map { Date(timeIntervalSince1970: $0) }
+ }
+
+ let expDate = epochDate("exp")
+
+ return JWTData(
+ headerJSON: headerStr,
+ payloadJSON: payloadStr,
+ algorithm: alg,
+ isExpired: expDate.map { $0 < Date() },
+ expirationDate: expDate,
+ issuedAtDate: epochDate("iat"),
+ subject: payloadObj["sub"] as? String,
+ issuer: payloadObj["iss"] as? String
+ )
+ }
+
+ private static func prettyJSON(_ object: [String: Any]) -> String {
+ guard let data = try? JSONSerialization.data(withJSONObject: object,
+ options: [.prettyPrinted, .sortedKeys]),
+ let string = String(data: data, encoding: .utf8) else {
+ return "{}"
+ }
+ return string
+ }
+
+ private static func decodeBase64URLJSON(_ base64URL: String) -> [String: Any]? {
+ var base64 = base64URL
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ while base64.count % 4 != 0 {
+ base64.append("=")
+ }
+ guard let data = Data(base64Encoded: base64),
+ let json = try? JSONSerialization.jsonObject(with: data, options: []),
+ let dict = json as? [String: Any] else {
+ return nil
+ }
+ return dict
+ }
+}
+
+public struct EpochData: Sendable, Equatable {
+ public let date: Date
+ public let unitDescription: String
+ public let localFormatted: String
+ public let iso8601: String
+ public let relativeFormatted: String
+ public let unixSeconds: Int64
+ public let unixMillis: Int64
+
+ private static let localFormatter: DateFormatter = {
+ let df = DateFormatter()
+ df.dateStyle = .full
+ df.timeStyle = .long
+ return df
+ }()
+
+ private static let isoFormatter: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return f
+ }()
+
+ private static let relativeFormatter: RelativeDateTimeFormatter = {
+ let f = RelativeDateTimeFormatter()
+ f.unitsStyle = .full
+ return f
+ }()
+
+ public static func parse(_ text: String?) -> EpochData? {
+ guard let text else { return nil }
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, trimmed.count >= 9, trimmed.count <= 22 else { return nil }
+
+ // Must be purely digits (or digits followed by decimal fractions)
+ let parts = trimmed.components(separatedBy: ".")
+ guard parts.count <= 2, parts[0].allSatisfy(\.isNumber) else { return nil }
+ if parts.count == 2 {
+ guard parts[1].allSatisfy(\.isNumber) else { return nil }
+ }
+
+ guard let rawDouble = Double(trimmed) else { return nil }
+
+ let date: Date
+ let unit: String
+
+ // Seconds: 10 digits (2001 to 2049); then milli/micro/nano scales.
+ if rawDouble >= 1_000_000_000 && rawDouble <= 2_500_000_000 {
+ date = Date(timeIntervalSince1970: rawDouble)
+ unit = "Seconds (10-digit)"
+ } else if rawDouble >= 1_000_000_000_000 && rawDouble <= 2_500_000_000_000 {
+ date = Date(timeIntervalSince1970: rawDouble / 1000.0)
+ unit = "Milliseconds (13-digit)"
+ } else if rawDouble >= 1_000_000_000_000_000 && rawDouble <= 2_500_000_000_000_000 {
+ date = Date(timeIntervalSince1970: rawDouble / 1_000_000.0)
+ unit = "Microseconds (16-digit)"
+ } else if rawDouble >= 1_000_000_000_000_000_000 && rawDouble <= 2_500_000_000_000_000_000 {
+ date = Date(timeIntervalSince1970: rawDouble / 1_000_000_000.0)
+ unit = "Nanoseconds (19-digit)"
+ } else {
+ return nil
+ }
+
+ let now = Date()
+ return EpochData(
+ date: date,
+ unitDescription: unit,
+ localFormatted: localFormatter.string(from: date),
+ iso8601: isoFormatter.string(from: date),
+ relativeFormatted: relativeFormatter.localizedString(for: date, relativeTo: now),
+ unixSeconds: Int64(date.timeIntervalSince1970),
+ unixMillis: Int64(date.timeIntervalSince1970 * 1000)
+ )
+ }
+}
diff --git a/Tests/ClapAppTests/AppStateTests.swift b/Tests/ClapAppTests/AppStateTests.swift
new file mode 100644
index 0000000..1e658af
--- /dev/null
+++ b/Tests/ClapAppTests/AppStateTests.swift
@@ -0,0 +1,79 @@
+import Testing
+import Foundation
+import AppKit
+import ClapCore
+@testable import ClapApp
+
+/// Verifies the hover-selection gate: opening the panel under a stationary
+/// cursor must not change the selection until the pointer moves, and the row
+/// under the cursor is then selected with the tiniest movement.
+@MainActor
+@Suite("AppState hover gate & query building")
+struct AppStateLogicTests {
+
+ private func makeState() throws -> AppState {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("clap-appstate-tests", isDirectory: true)
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let store = try ClipboardStore(dataDir: dir, now: { Date() })
+ let monitor = PasteboardMonitor(store: store)
+ return AppState(store: store, monitor: monitor)
+ }
+
+ @Test func hoverDoesNotStealSelectionWhileDisarmed() async throws {
+ let state = try makeState()
+ state.selectedID = 99
+ state.hoverChanged(1, hovering: true) // stationary cursor over row 1 on open
+ #expect(state.selectedID == 99)
+ }
+
+ @Test func firstMovementSelectsRowUnderCursor() async throws {
+ let state = try makeState()
+ state.selectedID = 99
+ state.hoverChanged(7, hovering: true) // pointer parked over entry 7
+ state.armPointer() // tiny physical movement
+ #expect(state.selectedID == 7)
+ }
+
+ @Test func leavingTheListClearsPendingHover() async throws {
+ let state = try makeState()
+ state.hoverChanged(7, hovering: true)
+ state.hoverChanged(7, hovering: false) // pointer moved off the list before arming
+ state.armPointer()
+ #expect(state.selectedID == nil)
+ }
+
+ @Test func armedHoverSelectsImmediately() async throws {
+ let state = try makeState()
+ state.armPointer()
+ state.hoverChanged(3, hovering: true)
+ #expect(state.selectedID == 3)
+ state.hoverChanged(4, hovering: true)
+ #expect(state.selectedID == 4)
+ }
+
+ @Test func panelWillShowDisarmsAgain() async throws {
+ let state = try makeState()
+ state.armPointer()
+ await state.panelWillShow()
+ #expect(state.pointerArmed == false)
+ }
+
+ @Test func defaultQueryPerTab() {
+ let classic = AppState.defaultQuery(tab: .classic, tag: nil, offset: 0)
+ #expect(classic?.types == [.text, .image])
+
+ let media = AppState.defaultQuery(tab: .media, tag: nil, offset: 40)
+ #expect(media?.type == .image)
+ #expect(media?.offset == 40)
+
+ let shell = AppState.defaultQuery(tab: .shell, tag: nil, offset: 0)
+ #expect(shell?.type == .shell)
+
+ let favs = AppState.defaultQuery(tab: .favs, tag: nil, offset: 0)
+ #expect(favs?.favoriteOnly == true)
+
+ let tagged = AppState.defaultQuery(tab: .favs, tag: "work", offset: 0)
+ #expect(tagged?.tag == "work")
+ }
+}
diff --git a/Tests/ClapCLITests/CLITests.swift b/Tests/ClapCLITests/CLITests.swift
new file mode 100644
index 0000000..a09d619
--- /dev/null
+++ b/Tests/ClapCLITests/CLITests.swift
@@ -0,0 +1,98 @@
+import Testing
+import Foundation
+import ClapCore
+@testable import ClapCLIKit
+
+@Suite("ArgParser")
+struct ArgParserTests {
+
+ private func parse(_ args: [String],
+ boolFlags: Set = [],
+ valueFlags: Set = []) -> ArgParser {
+ ArgParser.parse(args, boolFlags: boolFlags, valueFlags: valueFlags, usage: "usage")
+ }
+
+ @Test func collectsPositionals() {
+ #expect(parse(["a", "b", "c"]).positionals == ["a", "b", "c"])
+ }
+
+ @Test func recognizesBoolFlags() {
+ let parsed = parse(["--json", "x"], boolFlags: ["--json"])
+ #expect(parsed.has("--json"))
+ #expect(parsed.positionals == ["x"])
+ }
+
+ @Test func recognizesValueFlagWithSpaceAndEquals() {
+ #expect(parse(["--limit", "5"], valueFlags: ["--limit"]).value("--limit") == "5")
+ #expect(parse(["--limit=7"], valueFlags: ["--limit"]).value("--limit") == "7")
+ }
+
+ @Test func negativeNumbersArePositionalsNotFlags() {
+ #expect(parse(["-3"]).positionals == ["-3"])
+ }
+
+ @Test func intParsingWithMinimumAndDefaults() {
+ let parsed = parse(["--offset", "4"], valueFlags: ["--offset"])
+ #expect(parsed.int("--offset", default: 0, min: 0) == 4)
+ #expect(parsed.int("--missing", default: 9, min: 0) == 9)
+ }
+
+ @Test func validatedIDAcceptsPositiveIntegersOnly() {
+ #expect(ArgParser.validatedID("42") == 42)
+ #expect(ArgParser.validatedID("0") == nil)
+ #expect(ArgParser.validatedID("-5") == nil)
+ #expect(ArgParser.validatedID("abc") == nil)
+ #expect(ArgParser.validatedID("") == nil)
+ #expect(ArgParser.validatedID(nil) == nil)
+ }
+}
+
+@Suite("OutputFormatter")
+struct OutputFormatterTests {
+
+ @Test func previewFlattensAndTruncates() {
+ #expect(OutputFormatter.previewText("a\nb\tc") == "a b c")
+ let long = String(repeating: "x", count: 100)
+ let preview = OutputFormatter.previewText(long)
+ #expect(preview.count == 61) // 60 chars + ellipsis
+ #expect(preview.hasSuffix("…"))
+ }
+
+ @Test func imagePreviewFormat() {
+ let entry = ClipboardEntry(
+ id: 1, type: .image, content: nil, imagePath: "x.png", imageFormat: "png",
+ contentHash: "h", createdAt: Date(), lastUsedAt: Date(), sizeBytes: 2048,
+ isPinned: false, isFavorite: false, useCount: 1, sourceApp: nil)
+ #expect(OutputFormatter.preview(entry) == "[image png, 2.0 KB]")
+ }
+
+ @Test func relativeTimeMatchesCoreBuckets() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ #expect(OutputFormatter.relativeTime(now.addingTimeInterval(-300), now: now) == "5m")
+ #expect(OutputFormatter.relativeTime(now.addingTimeInterval(-7200), now: now) == "2h")
+ }
+
+ @Test func tableAlignsColumns() {
+ let entry = ClipboardEntry(
+ id: 7, type: .text, content: "hello", imagePath: nil, imageFormat: nil,
+ contentHash: "h", createdAt: Date(), lastUsedAt: Date(), sizeBytes: 5,
+ isPinned: true, isFavorite: false, useCount: 1, sourceApp: nil)
+ let table = OutputFormatter.table([entry])
+ let lines = table.split(separator: "\n").map(String.init)
+ #expect(lines.count == 2)
+ #expect(lines[0].hasPrefix("ID"))
+ #expect(lines[1].contains("*"))
+ #expect(lines[1].contains("hello"))
+ }
+
+ @Test func encodeJSONProducesSortedStableOutput() throws {
+ struct Payload: Codable, Equatable { let b: Int; let a: Int }
+ let json = try OutputFormatter.encodeJSON(Payload(b: 1, a: 2))
+ #expect(json.contains("\"a\""))
+ #expect(json.contains("\"b\""))
+ // sortedKeys: "a" appears before "b"
+ let aRange = try #require(json.range(of: "\"a\""))
+ let bRange = try #require(json.range(of: "\"b\""))
+ #expect(aRange.lowerBound < bRange.lowerBound)
+ }
+}
diff --git a/Tests/ClapCoreTests/ImageTests.swift b/Tests/ClapCoreTests/ImageTests.swift
index 747d2ad..b9381e3 100644
--- a/Tests/ClapCoreTests/ImageTests.swift
+++ b/Tests/ClapCoreTests/ImageTests.swift
@@ -5,7 +5,7 @@ import Testing
@Suite("Image capture & thumbnails")
struct ImageTests {
@Test func captureImageWritesFileAndRow() async throws {
- try await withStore { store, dir in
+ try await withStore { store, _ in
let png = makePNG(width: 12, height: 6)
let result = try #require(try await store.captureImage(data: png, format: "PNG", sourceApp: "com.test.app"))
let entry = result.entry
@@ -36,15 +36,16 @@ struct ImageTests {
}
@Test func duplicateImageTouchesWithoutRewritingFile() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
let png = makePNG()
+ clock.advance(-100)
let first = try #require(try await store.captureImage(data: png, format: "png", sourceApp: nil))
let fileURL = try #require(await store.imageFileURL(for: first.entry))
let originalModDate = try FileManager.default
.attributesOfItem(atPath: fileURL.path)[.modificationDate] as? Date
- try await store._test_setTimestamps(id: first.entry.id,
- lastUsedAt: Date(timeIntervalSinceNow: -100))
+ clock.advance(100)
let second = try #require(try await store.captureImage(data: png, format: "png", sourceApp: nil))
#expect(second.wasDuplicate == true)
#expect(second.entry.id == first.entry.id)
diff --git a/Tests/ClapCoreTests/OCRSeamTests.swift b/Tests/ClapCoreTests/OCRSeamTests.swift
new file mode 100644
index 0000000..7c99d15
--- /dev/null
+++ b/Tests/ClapCoreTests/OCRSeamTests.swift
@@ -0,0 +1,68 @@
+import Testing
+import os
+import Foundation
+@testable import ClapCore
+
+/// Deterministic OCR stub: no Vision, no latency, fully predictable.
+struct MockOCREngine: OCREngine {
+ let result: String?
+
+ func recognizeText(from imageData: Data) async -> String? {
+ result
+ }
+}
+
+@Suite("OCR seam & injected clock")
+struct OCRSeamTests {
+
+ @Test func captureImageStoresMockedOCRTextAndSearchFindsIt() async throws {
+ let png = makePNG()
+ try await withStore { store, _ in
+ let seeded = try ClipboardStore(dataDir: store.dataDir,
+ now: { Date() },
+ ocr: MockOCREngine(result: "hello receipt"))
+ _ = try await seeded.captureImage(data: png, format: "png", sourceApp: nil)
+
+ let entry = try await seeded.list(type: .image, limit: 1, offset: 0).first
+ #expect(entry != nil)
+ #expect(entry?.content == "hello receipt")
+
+ let hits = try await seeded.search(SearchQuery(text: "receipt", limit: 10, offset: 0))
+ #expect(hits.count == 1)
+ }
+ }
+
+ @Test func captureImageWithoutOCRTextStoresNilContent() async throws {
+ let png = makePNG()
+ try await withStore { store, _ in
+ let seeded = try ClipboardStore(dataDir: store.dataDir,
+ ocr: MockOCREngine(result: nil))
+ _ = try await seeded.captureImage(data: png, format: "png", sourceApp: nil)
+ let entry = try await seeded.list(type: .image, limit: 1, offset: 0).first
+ #expect(entry?.content == nil)
+ }
+ }
+
+ @Test func injectedClockDrivesTimestamps() async throws {
+ let fixed = Date(timeIntervalSince1970: 1_700_000_000)
+ try await withStore { store, _ in
+ let seeded = try ClipboardStore(dataDir: store.dataDir, now: { fixed })
+ let (entry, _) = try await seeded.captureText("clock test", sourceApp: nil)!
+ #expect(entry.createdAt == fixed)
+ #expect(entry.lastUsedAt == fixed)
+ }
+ }
+
+ @Test func recencyOrderingUsesInjectedClockProgression() async throws {
+ let clockState = OSAllocatedUnfairLock(initialState: Date(timeIntervalSince1970: 1_700_000_000))
+ try await withStore { store, _ in
+ let seeded = try ClipboardStore(dataDir: store.dataDir,
+ now: { clockState.withLock { $0 } })
+ _ = try await seeded.captureText("older", sourceApp: nil)!
+ clockState.withLock { $0 = $0.addingTimeInterval(60) }
+ _ = try await seeded.captureText("newer", sourceApp: nil)
+ let list = try await seeded.list(type: .text, limit: 10, offset: 0)
+ #expect(list.first?.content == "newer")
+ }
+ }
+}
diff --git a/Tests/ClapCoreTests/SearchQueryParseTests.swift b/Tests/ClapCoreTests/SearchQueryParseTests.swift
index 73bdbda..0e1edd9 100644
--- a/Tests/ClapCoreTests/SearchQueryParseTests.swift
+++ b/Tests/ClapCoreTests/SearchQueryParseTests.swift
@@ -73,7 +73,7 @@ struct QueryTokenizerTests {
#expect(tokens == [
.init(value: "a", quoted: false),
.init(value: "b c", quoted: true),
- .init(value: "d", quoted: false),
+ .init(value: "d", quoted: false)
])
}
@@ -81,7 +81,7 @@ struct QueryTokenizerTests {
let tokens = QueryTokenizer.tokenize("x \"tail end")
#expect(tokens == [
.init(value: "x", quoted: false),
- .init(value: "tail end", quoted: false),
+ .init(value: "tail end", quoted: false)
])
}
}
diff --git a/Tests/ClapCoreTests/StoreTests.swift b/Tests/ClapCoreTests/StoreTests.swift
index bae6dd9..b9194fd 100644
--- a/Tests/ClapCoreTests/StoreTests.swift
+++ b/Tests/ClapCoreTests/StoreTests.swift
@@ -30,11 +30,11 @@ struct CaptureTests {
}
@Test func duplicateTextTouchesInsteadOfInserting() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -100)
let first = try #require(try await store.captureText("dup me", sourceApp: nil))
- // Backdate so the recency bump is observable.
- try await store._test_setTimestamps(id: first.entry.id,
- lastUsedAt: Date(timeIntervalSinceNow: -100))
+ clock.advance(60)
let second = try #require(try await store.captureText(" dup me ", sourceApp: nil))
#expect(second.wasDuplicate == true)
#expect(second.entry.id == first.entry.id)
@@ -53,18 +53,20 @@ struct CaptureTests {
@Suite("Listing & recency")
struct ListTests {
@Test func listOrdersByRecency() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -30)
let a = try #require(try await store.captureText("alpha", sourceApp: nil)).entry
+ clock.value = Date(timeIntervalSinceNow: -20)
let b = try #require(try await store.captureText("beta", sourceApp: nil)).entry
+ clock.value = Date(timeIntervalSinceNow: -10)
let c = try #require(try await store.captureText("gamma", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: a.id, lastUsedAt: Date(timeIntervalSinceNow: -30))
- try await store._test_setTimestamps(id: b.id, lastUsedAt: Date(timeIntervalSinceNow: -20))
- try await store._test_setTimestamps(id: c.id, lastUsedAt: Date(timeIntervalSinceNow: -10))
var listed = try await store.list(type: nil, limit: 100, offset: 0)
#expect(listed.map(\.content) == ["gamma", "beta", "alpha"])
// touch bumps recency to the front
+ clock.advance(10_000)
try await store.touch(id: a.id)
listed = try await store.list(type: nil, limit: 100, offset: 0)
#expect(listed.map(\.content) == ["alpha", "gamma", "beta"])
@@ -73,10 +75,12 @@ struct ListTests {
}
@Test func listPaginationAndTypeFilter() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -10)
for i in 0..<5 {
- let e = try #require(try await store.captureText("item \(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 10)))
+ _ = try #require(try await store.captureText("item \(i)", sourceApp: nil))
+ clock.advance(1)
}
_ = try #require(try await store.captureImage(data: makePNG(), format: "png", sourceApp: nil))
@@ -257,10 +261,12 @@ struct SearchTests {
}
@Test func searchLimitAndOffset() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -100)
for i in 0..<10 {
- let e = try #require(try await store.captureText("common token \(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 100)))
+ _ = try #require(try await store.captureText("common token \(i)", sourceApp: nil))
+ clock.advance(1)
}
let page1 = try await store.search(SearchQuery(text: "common", limit: 3, offset: 0))
let page2 = try await store.search(SearchQuery(text: "common", limit: 3, offset: 3))
@@ -341,11 +347,13 @@ struct DeleteTests {
@Suite("Eviction & retention")
struct EvictionTests {
@Test func countEvictionRemovesOldestNonPinned() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
try await store.setConfig("text.max_entries", value: "3")
+ clock.value = Date(timeIntervalSinceNow: -100)
for i in 0..<5 {
- let e = try #require(try await store.captureText("entry \(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 100)))
+ _ = try #require(try await store.captureText("entry \(i)", sourceApp: nil))
+ clock.advance(1)
}
let evicted = try await store.enforceLimits()
#expect(evicted == 2)
@@ -355,11 +363,13 @@ struct EvictionTests {
}
@Test func byteSizeEviction() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
let payload = String(repeating: "x", count: 100) // 101 bytes with suffix digit
+ clock.value = Date(timeIntervalSinceNow: -100)
for i in 0..<5 {
- let e = try #require(try await store.captureText("\(payload)\(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 100)))
+ _ = try #require(try await store.captureText("\(payload)\(i)", sourceApp: nil))
+ clock.advance(1)
}
// 5 entries x 101 bytes = 505 bytes; cap at 250 -> keep 2 newest.
try await store.setConfig("text.max_size", value: "250")
@@ -372,13 +382,15 @@ struct EvictionTests {
}
@Test func pinnedEntriesAreImmuneToEviction() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
try await store.setConfig("text.max_entries", value: "2")
var oldest: Int64 = 0
+ clock.value = Date(timeIntervalSinceNow: -100)
for i in 0..<4 {
let e = try #require(try await store.captureText("pin test \(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 100)))
if i == 0 { oldest = e.id }
+ clock.advance(1)
}
_ = try await store.setPinned(true, id: oldest)
// Pinned rows live outside the budget: 3 non-pinned vs cap 2.
@@ -420,10 +432,12 @@ struct EvictionTests {
}
@Test func loweringByteCapEvictsOversizeEntryNotWholeHistory() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -100)
for i in 0..<3 {
- let e = try #require(try await store.captureText("keep me \(i)", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: e.id, lastUsedAt: Date(timeIntervalSinceNow: Double(i - 100)))
+ _ = try #require(try await store.captureText("keep me \(i)", sourceApp: nil))
+ clock.advance(1)
}
let big = try #require(try await store.captureText(String(repeating: "y", count: 500), sourceApp: nil)).entry
// Cap now smaller than the big (newest) entry alone. The big
@@ -438,10 +452,12 @@ struct EvictionTests {
}
@Test func imageEvictionDeletesFiles() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
try await store.setConfig("image.max_entries", value: "1")
+ clock.value = Date(timeIntervalSinceNow: -100)
let img1 = try #require(try await store.captureImage(data: makePNG(red: 0.1), format: "png", sourceApp: nil)).entry
- try await store._test_setTimestamps(id: img1.id, lastUsedAt: Date(timeIntervalSinceNow: -100))
+ clock.advance(100)
let img2 = try #require(try await store.captureImage(data: makePNG(red: 0.9), format: "png", sourceApp: nil)).entry
let url1 = try #require(await store.imageFileURL(for: img1))
let thumb1 = try #require(try await store.thumbnailURL(for: img1))
@@ -458,13 +474,13 @@ struct EvictionTests {
}
@Test func retentionDeletesOldNonPinned() async throws {
- try await withStore { store, _ in
+ let clock = TestClock()
+ try await withStore(clock: clock) { store, _ in
+ clock.value = Date(timeIntervalSinceNow: -40 * 86_400)
let old = try #require(try await store.captureText("ancient history", sourceApp: nil)).entry
let oldPinned = try #require(try await store.captureText("ancient but pinned", sourceApp: nil)).entry
+ clock.advance(40 * 86_400)
_ = try await store.captureText("fresh", sourceApp: nil)
- let fortyDaysAgo = Date(timeIntervalSinceNow: -40 * 86_400)
- try await store._test_setTimestamps(id: old.id, lastUsedAt: fortyDaysAgo)
- try await store._test_setTimestamps(id: oldPinned.id, lastUsedAt: fortyDaysAgo)
_ = try await store.setPinned(true, id: oldPinned.id)
// retention.days = 0 -> never delete.
@@ -786,7 +802,7 @@ struct ShellHistoryStoreTests {
@Test func shortcutCRUDAndDictionary() async throws {
try await withStore { store, _ in
- let emailEntry = try #require(try await store.captureText("himanshu.kumar@grofers.com", sourceApp: nil)).entry
+ let emailEntry = try #require(try await store.captureText("user@example.com", sourceApp: nil)).entry
let zoomEntry = try #require(try await store.captureText("https://zoom.us/j/12345", sourceApp: nil)).entry
_ = try await store.setShortcut(";email", id: emailEntry.id)
@@ -796,14 +812,76 @@ struct ShellHistoryStoreTests {
#expect(reloaded.shortcut == ";email")
let all = try await store.allShortcuts()
- #expect(all[";email"] == "himanshu.kumar@grofers.com")
+ #expect(all[";email"] == "user@example.com")
#expect(all[";zoom"] == "https://zoom.us/j/12345")
// Remove shortcut
_ = try await store.setShortcut(nil, id: zoomEntry.id)
let updated = try await store.allShortcuts()
- #expect(updated[";email"] == "himanshu.kumar@grofers.com")
+ #expect(updated[";email"] == "user@example.com")
#expect(updated[";zoom"] == nil)
}
}
+
+ @Test func tagCRUDAndFiltering() async throws {
+ try await withStore { store, _ in
+ let e1 = try #require(try await store.captureText("SELECT * FROM users WHERE active = 1;", sourceApp: nil)).entry
+ let e2 = try #require(try await store.captureText("SELECT * FROM orders WHERE paid = 1;", sourceApp: nil)).entry
+ let e3 = try #require(try await store.captureText("Hi team, here is the weekly recap.", sourceApp: nil)).entry
+
+ // Add tags
+ _ = try await store.addTag("sql", entryID: e1.id)
+ _ = try await store.addTag("#work", entryID: e1.id)
+ _ = try await store.addTag("sql", entryID: e2.id)
+ _ = try await store.addTag("email", entryID: e3.id)
+ _ = try await store.addTag("work", entryID: e3.id)
+
+ // Verify entry hydration
+ let reloaded1 = try #require(try await store.entry(id: e1.id))
+ #expect(reloaded1.tags.contains("sql"))
+ #expect(reloaded1.tags.contains("work"))
+
+ // Verify allTags
+ let all = try await store.allTags()
+ let tagMap = Dictionary(uniqueKeysWithValues: all.map { ($0.tag, $0.count) })
+ #expect(tagMap["sql"] == 2)
+ #expect(tagMap["work"] == 2)
+ #expect(tagMap["email"] == 1)
+
+ // Filter search by tag
+ let sqlEntries = try await store.search(SearchQuery(tag: "sql"))
+ #expect(sqlEntries.count == 2)
+ #expect(sqlEntries.map(\.id).contains(e1.id))
+ #expect(sqlEntries.map(\.id).contains(e2.id))
+
+ let emailEntries = try await store.search(SearchQuery(tag: "#email"))
+ #expect(emailEntries.count == 1)
+ #expect(emailEntries[0].id == e3.id)
+
+ // Remove a tag
+ _ = try await store.removeTag("work", entryID: e1.id)
+ let tagsAfterRemove = try await store.tags(for: e1.id)
+ #expect(tagsAfterRemove == ["sql"])
+
+ // Set tags batch
+ try await store.setTags(["db", "analytics"], entryID: e2.id)
+ let e2Tags = try await store.tags(for: e2.id)
+ #expect(e2Tags.sorted() == ["analytics", "db"])
+ }
+ }
+
+ @Test func tagCascadeOnDelete() async throws {
+ try await withStore { store, _ in
+ let e = try #require(try await store.captureText("Temporary note with tags", sourceApp: nil)).entry
+ _ = try await store.addTag("temporary", entryID: e.id)
+ _ = try await store.addTag("scratch", entryID: e.id)
+
+ #expect(try await store.tags(for: e.id).count == 2)
+
+ // Delete entry
+ _ = try await store.delete(id: e.id)
+ let all = try await store.allTags()
+ #expect(all.isEmpty)
+ }
+ }
}
diff --git a/Tests/ClapCoreTests/TestSupport.swift b/Tests/ClapCoreTests/TestSupport.swift
index 4abfe14..37837a9 100644
--- a/Tests/ClapCoreTests/TestSupport.swift
+++ b/Tests/ClapCoreTests/TestSupport.swift
@@ -2,15 +2,34 @@ import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
+import os
@testable import ClapCore
+/// Settable clock for deterministic timestamp control via ClipboardStore's
+/// injected `now:`. Thread-safe: read on the store's executor, written from
+/// test tasks.
+final class TestClock: @unchecked Sendable {
+ private let state = OSAllocatedUnfairLock(initialState: Date())
+
+ var value: Date {
+ get { state.withLock { $0 } }
+ set { state.withLock { $0 = newValue } }
+ }
+
+ func advance(_ seconds: TimeInterval) {
+ value = value.addingTimeInterval(seconds)
+ }
+}
+
/// Creates a store in a unique temp data dir, runs `body`, cleans up.
-func withStore(_ body: (ClipboardStore, URL) async throws -> T) async throws -> T {
+/// Pass a clock to drive timestamps deterministically.
+func withStore(clock: TestClock = TestClock(),
+ _ body: (ClipboardStore, URL) async throws -> T) async throws -> T {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clap-tests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: dir) }
- let store = try ClipboardStore(dataDir: dir)
+ let store = try ClipboardStore(dataDir: dir, now: { clock.value })
return try await body(store, dir)
}
diff --git a/Tests/ClapCoreTests/TextAnalysisTests.swift b/Tests/ClapCoreTests/TextAnalysisTests.swift
new file mode 100644
index 0000000..acc4a8c
--- /dev/null
+++ b/Tests/ClapCoreTests/TextAnalysisTests.swift
@@ -0,0 +1,156 @@
+import Testing
+import Foundation
+@testable import ClapCore
+
+@Suite("Text analysis")
+struct TextAnalysisTests {
+
+ // MARK: ColorParser
+
+ @Test func parsesHexFormats() {
+ #expect(ColorParser.parse("#f00") == ParsedColor(red: 1, green: 0, blue: 0, alpha: 1))
+ #expect(ColorParser.parse("#ff0000") == ParsedColor(red: 1, green: 0, blue: 0, alpha: 1))
+ let rgba = ColorParser.parse("#ff000080")
+ #expect(rgba?.alpha ?? 0 < 0.51)
+ #expect(rgba?.red == 1)
+ #expect(ColorParser.parse("0xff0000")?.green == 0)
+ }
+
+ @Test func parsesRgbAndHslFunctions() {
+ let rgb = ColorParser.parse("rgb(255, 0, 0)")
+ #expect(rgb?.red == 1)
+ #expect(rgb?.blue == 0)
+ let hsl = ColorParser.parse("hsl(0, 100%, 50%)")
+ #expect(hsl != nil)
+ }
+
+ @Test func hslConvertsToCorrectRgb() throws {
+ // Primary hues: hsl(0,100%,50%)=red, hsl(120,...)=green, hsl(240,...)=blue.
+ let red = try #require(ColorParser.parse("hsl(0, 100%, 50%)"))
+ #expect(red.red == 1 && red.green == 0 && red.blue == 0)
+
+ let green = try #require(ColorParser.parse("hsl(120, 100%, 50%)"))
+ #expect(green.red == 0 && green.green == 1 && green.blue == 0)
+
+ let blue = try #require(ColorParser.parse("hsl(240, 100%, 50%)"))
+ #expect(blue.red == 0 && blue.green == 0 && blue.blue == 1)
+
+ // White and black have zero saturation.
+ let white = try #require(ColorParser.parse("hsl(0, 0%, 100%)"))
+ #expect(white.red == 1 && white.green == 1 && white.blue == 1)
+ let black = try #require(ColorParser.parse("hsl(0, 0%, 0%)"))
+ #expect(black.red == 0 && black.green == 0 && black.blue == 0)
+
+ // Hue wraps: -120deg ≡ 240deg (pure blue).
+ let wrapped = try #require(ColorParser.parse("hsl(-120, 100%, 50%)"))
+ #expect(wrapped == blue)
+ }
+
+ @Test func rejectsNonColors() {
+ #expect(ColorParser.parse("hello world") == nil)
+ #expect(ColorParser.parse("#12345") == nil)
+ #expect(ColorParser.parse(nil) == nil)
+ }
+
+ // MARK: CaseConverter
+
+ @Test func convertsCaseStyles() {
+ #expect(CaseConverter.convert("hello world test", to: .camelCase) == "helloWorldTest")
+ #expect(CaseConverter.convert("hello world", to: .pascalCase) == "HelloWorld")
+ #expect(CaseConverter.convert("HelloWorld", to: .snakeCase) == "hello_world")
+ #expect(CaseConverter.convert("hello world", to: .kebabCase) == "hello-world")
+ #expect(CaseConverter.convert("hello world", to: .constantCase) == "HELLO_WORLD")
+ }
+
+ // MARK: TextTransformer
+
+ @Test func base64RoundTrip() {
+ let encoded = TextTransformer.encodeBase64("clap")
+ #expect(encoded == Data("clap".utf8).base64EncodedString())
+ #expect(TextTransformer.decodeBase64(encoded) == "clap")
+ }
+
+ @Test func decodeBase64RejectsPlainWords() {
+ #expect(TextTransformer.decodeBase64("hello") == nil)
+ #expect(TextTransformer.decodeBase64("not base64!!!") == nil)
+ }
+
+ @Test func urlRoundTrip() {
+ let encoded = TextTransformer.encodeURL("a b&c=d")
+ #expect(TextTransformer.decodeURL(encoded) == "a b&c=d")
+ #expect(TextTransformer.decodeURL("nothing to decode") == nil)
+ }
+
+ // MARK: JWTData
+
+ @Test func parsesWellFormedJWT() throws {
+ func b64(_ json: String) -> String {
+ Data(json.utf8).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+ let header = b64(#"{"alg":"HS256","typ":"JWT"}"#)
+ let payload = b64(#"{"sub":"user-1","iss":"clap","exp":4102444800}"#)
+ let token = "\(header).\(payload).signature"
+
+ let jwt = try #require(JWTData.parse(token))
+ #expect(jwt.algorithm == "HS256")
+ #expect(jwt.subject == "user-1")
+ #expect(jwt.issuer == "clap")
+ #expect(jwt.isExpired == false)
+ #expect(jwt.payloadJSON.contains("user-1"))
+ }
+
+ @Test func rejectsNonJWT() {
+ #expect(JWTData.parse("a.b.c") == nil)
+ #expect(JWTData.parse("not a token") == nil)
+ }
+
+ // MARK: EpochData
+
+ @Test func parsesSecondAndMillisecondTimestamps() throws {
+ let seconds = try #require(EpochData.parse("1700000000"))
+ #expect(seconds.unitDescription.contains("Seconds"))
+ #expect(seconds.unixSeconds == 1_700_000_000)
+
+ let millis = try #require(EpochData.parse("1700000000000"))
+ #expect(millis.unitDescription.contains("Milliseconds"))
+ #expect(millis.unixSeconds == 1_700_000_000)
+ }
+
+ @Test func rejectsOutOfRangeNumbers() {
+ #expect(EpochData.parse("42") == nil)
+ #expect(EpochData.parse("99999999999999999999999") == nil)
+ #expect(EpochData.parse("not a number") == nil)
+ }
+
+ // MARK: TextSummaries
+
+ @Test func singleLineCollapsesAndTruncates() {
+ #expect(TextSummaries.singleLine(" a\n\nb\t c ", maxChars: 100) == "a b c")
+ let truncated = TextSummaries.singleLine(String(repeating: "x", count: 100), maxChars: 10)
+ #expect(truncated.count == 11)
+ #expect(truncated.hasSuffix("…"))
+ }
+
+ @Test func relativeTimeBuckets() {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-30), now: now) == "now")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-300), now: now) == "5m")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-7200), now: now) == "2h")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-3 * 86_400), now: now) == "3d")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-14 * 86_400), now: now) == "2w")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-60 * 86_400), now: now) == "2mo")
+ #expect(TextSummaries.relativeTime(now.addingTimeInterval(-400 * 86_400), now: now) == "1y")
+ }
+
+ // MARK: ImageFormats
+
+ @Test func mapsKnownFormatsToUTIs() {
+ #expect(ImageFormats.uti(forFormat: "png") == "public.png")
+ #expect(ImageFormats.uti(forFormat: "gif") == "com.compuserve.gif")
+ #expect(ImageFormats.uti(forFormat: "JPEG") == "public.jpeg")
+ #expect(ImageFormats.uti(forFormat: "bogus") == nil)
+ }
+}
diff --git a/install.sh b/install.sh
index 0eea52e..594e974 100755
--- a/install.sh
+++ b/install.sh
@@ -103,6 +103,6 @@ open "$APP_DEST"
echo
echo "${GREEN}${BOLD}==> clap is ready!${RESET}"
echo " • Shortcut: ${BOLD}⌘ ⇧ V${RESET} (open clipboard & shell history panel)"
-echo " • Tabs: ${BOLD}⌘1${RESET} Classic · ${BOLD}⌘2${RESET} Media · ${BOLD}⌘3${RESET} Shell"
+echo " • Tabs: ${BOLD}⌘1${RESET} Classic · ${BOLD}⌘2${RESET} Shell · ${BOLD}⌘3${RESET} Favs · ${BOLD}⌘4${RESET} Media"
echo " • CLI: ${BOLD}clap --help${RESET} or ${BOLD}clap stats${RESET}"
echo