From fce7020dcb87f353160a6e27f6a022243339557b Mon Sep 17 00:00:00 2001 From: spongycode Date: Fri, 21 Aug 2026 10:54:20 +0530 Subject: [PATCH 1/7] Implement tags and custom pinboards - Add `entry_tags` table and database migration to version 2. - Implement tag CRUD operations and filtering in `ClipboardStore`. - Support `tag:` syntax in search queries and CLI commands. - Add tag management UI, including a new `TagEditorWindow` and filter pills in the Favorites tab. - Update `ClipboardEntry` model to include tag metadata. - Register new `tag` and `tags` commands in the CLI. - Bump version to 0.2.0. --- Scripts/Info.plist | 4 +- Sources/ClapApp/ContentView.swift | 61 ++++++ Sources/ClapApp/PreviewPanel.swift | 31 +++ Sources/ClapApp/RowViews.swift | 18 ++ Sources/ClapApp/SettingsView.swift | 4 +- Sources/ClapApp/TagEditorWindow.swift | 214 +++++++++++++++++++ Sources/ClapApp/ViewModel.swift | 74 ++++++- Sources/ClapCLI/Commands/ListCommand.swift | 19 +- Sources/ClapCLI/Commands/SearchCommand.swift | 11 +- Sources/ClapCLI/Commands/TagCommand.swift | 114 ++++++++++ Sources/ClapCLI/Main.swift | 8 +- Sources/ClapCore/ClipboardStore.swift | 113 +++++++++- Sources/ClapCore/Database.swift | 22 +- Sources/ClapCore/Models.swift | 20 +- Tests/ClapCoreTests/StoreTests.swift | 62 ++++++ 15 files changed, 738 insertions(+), 37 deletions(-) create mode 100644 Sources/ClapApp/TagEditorWindow.swift create mode 100644 Sources/ClapCLI/Commands/TagCommand.swift 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/ContentView.swift b/Sources/ClapApp/ContentView.swift index eca9c75..b37b074 100644 --- a/Sources/ClapApp/ContentView.swift +++ b/Sources/ClapApp/ContentView.swift @@ -65,6 +65,9 @@ struct ContentView: View { state.onOpenSettings?() } } + if state.tab == .favs { + tagFilterBar + } if let error = state.searchError { Text(error) .font(.caption) @@ -76,6 +79,64 @@ struct ContentView: View { .padding(.vertical, 10) } + /// 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.selectedTag = 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.selectedTag = 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(0.06)) + ) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 3.5) + .foregroundStyle(isSelected ? Color.white : Color.primary) + .background( + Capsule() + .fill(isSelected ? Color.accentColor : Color.primary.opacity(0.06)) + ) + } + .buttonStyle(.plain) + } + /// Settings gear button with circular hover highlight private struct SettingsButton: View { let action: () -> Void diff --git a/Sources/ClapApp/PreviewPanel.swift b/Sources/ClapApp/PreviewPanel.swift index 07cd67a..98f4f90 100644 --- a/Sources/ClapApp/PreviewPanel.swift +++ b/Sources/ClapApp/PreviewPanel.swift @@ -640,6 +640,17 @@ struct PreviewView: View { .help(entry.shortcut != nil ? "Edit snippet expansion shortcut (\(entry.shortcut!))" : "Assign a text abbreviation (e.g. ;email) to auto-expand 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: { @@ -681,6 +692,26 @@ struct PreviewView: View { .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 + Text("#\(tag)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(.blue) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background( + Capsule() + .fill(Color.blue.opacity(0.12)) + ) + } + } + } + } + } if entry.isPinned { GridRow { metaLabel("Pinned") diff --git a/Sources/ClapApp/RowViews.swift b/Sources/ClapApp/RowViews.swift index cb2cae6..a79676f 100644 --- a/Sources/ClapApp/RowViews.swift +++ b/Sources/ClapApp/RowViews.swift @@ -61,6 +61,21 @@ struct EntryRow: View { ) ) } + ForEach(entry.tags.prefix(2), id: \.self) { tag in + 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) + ) + ) + } if entry.isPinned { Image(systemName: "pin.fill") .font(.system(size: 12.5)) @@ -95,6 +110,9 @@ struct EntryRow: View { } .contextMenu { Button("Copy") { state.copy(entry) } + Button(entry.tags.isEmpty ? "Manage Tags…" : "Manage Tags (\(entry.tags.map { "#\($0)" }.joined(separator: ", ")))…") { + state.promptManageTags(entry) + } if (entry.type == .text || entry.type == .shell) { Button(entry.shortcut == nil ? "Set Snippet Shortcut…" : "Edit Snippet Shortcut (\(entry.shortcut!))…") { state.promptSetShortcut(entry) diff --git a/Sources/ClapApp/SettingsView.swift b/Sources/ClapApp/SettingsView.swift index 9067d58..6cdccd0 100644 --- a/Sources/ClapApp/SettingsView.swift +++ b/Sources/ClapApp/SettingsView.swift @@ -110,8 +110,8 @@ 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) } } diff --git a/Sources/ClapApp/TagEditorWindow.swift b/Sources/ClapApp/TagEditorWindow.swift new file mode 100644 index 0000000..1aa2611 --- /dev/null +++ b/Sources/ClapApp/TagEditorWindow.swift @@ -0,0 +1,214 @@ +import SwiftUI +import AppKit +import ClapCore + +/// Manages the dedicated titled window for managing tags on an entry. +@MainActor +final class TagWindowController: NSObject, NSWindowDelegate { + static let shared = TagWindowController() + + private var window: NSWindow? + private var currentEntry: ClipboardEntry? + + func show(for entry: ClipboardEntry, state: AppState) { + currentEntry = entry + + let allTags = state.availableTags.map(\.tag) + let view = TagEditorView( + entry: entry, + suggestedTags: allTags, + onSave: { [weak self] newTags in + state.setTags(newTags, for: entry) + self?.close() + }, + onCancel: { [weak self] in + self?.close() + } + ) + + if window == nil { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Manage Tags & Pinboards" + 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() + } +} + +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(0.06)) + .overlay( + Capsule() + .strokeBorder(Color.primary.opacity(0.12), 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) + .background(Color.primary.opacity(0.02)) + } + .frame(width: 440) + .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/ViewModel.swift b/Sources/ClapApp/ViewModel.swift index 23f8259..fd1836a 100644 --- a/Sources/ClapApp/ViewModel.swift +++ b/Sources/ClapApp/ViewModel.swift @@ -56,6 +56,16 @@ final class AppState: ObservableObject { @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 var selectedTag: String? = nil { + didSet { + guard oldValue != selectedTag else { return } + selectedID = nil + reload() + } + } + /// 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 @@ -124,7 +134,13 @@ 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 } @@ -133,6 +149,7 @@ final class AppState: ObservableObject { generation += 1 let gen = generation let currentTab = tab + let currentTag = selectedTag let query = buildQuery(offset: 0) Task { @MainActor [weak self] in @@ -151,7 +168,11 @@ final class AppState: ObservableObject { } 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)) + if let currentTag { + fetched = try await self.store.search(SearchQuery(tag: currentTag, limit: Self.pageSize, offset: 0)) + } else { + fetched = try await self.store.search(SearchQuery(favoriteOnly: true, limit: Self.pageSize, offset: 0)) + } } } guard gen == self.generation else { return } @@ -170,6 +191,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 +212,7 @@ final class AppState: ObservableObject { isLoadingMore = true let gen = generation let currentTab = tab + let currentTag = selectedTag let offset = fetchedCount let query = buildQuery(offset: offset) @@ -207,7 +230,11 @@ final class AppState: ObservableObject { } 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)) + if let currentTag { + fetched = try await self.store.search(SearchQuery(tag: currentTag, limit: Self.pageSize, offset: offset)) + } else { + fetched = try await self.store.search(SearchQuery(favoriteOnly: true, limit: Self.pageSize, offset: offset)) + } } guard gen == self.generation else { return } self.fetchedCount += fetched.count @@ -321,6 +348,47 @@ final class AppState: ObservableObject { } } + func refreshTags() { + Task { @MainActor [weak self] in + guard let self else { return } + self.availableTags = (try? await self.store.allTags()) ?? [] + } + } + + 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 } + _ = try? await self.store.addTag(tag, entryID: entry.id) + IPC.post(.storeChanged) + self.reload() + self.refreshTags() + } + } + + func removeTag(_ tag: String, from entry: ClipboardEntry) { + Task { @MainActor [weak self] in + guard let self else { return } + _ = try? await self.store.removeTag(tag, entryID: entry.id) + IPC.post(.storeChanged) + self.reload() + self.refreshTags() + } + } + + func setTags(_ tags: [String], for entry: ClipboardEntry) { + Task { @MainActor [weak self] in + guard let self else { return } + try? await self.store.setTags(tags, entryID: entry.id) + IPC.post(.storeChanged) + self.reload() + self.refreshTags() + } + } + // MARK: - Copy to pasteboard /// Writes the entry to NSPasteboard.general. The monitor is told about diff --git a/Sources/ClapCLI/Commands/ListCommand.swift b/Sources/ClapCLI/Commands/ListCommand.swift index 2a76913..6bb51b3 100644 --- a/Sources/ClapCLI/Commands/ListCommand.swift +++ b/Sources/ClapCLI/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) } diff --git a/Sources/ClapCLI/Commands/SearchCommand.swift b/Sources/ClapCLI/Commands/SearchCommand.swift index 5347090..f715899 100644 --- a/Sources/ClapCLI/Commands/SearchCommand.swift +++ b/Sources/ClapCLI/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/TagCommand.swift b/Sources/ClapCLI/Commands/TagCommand.swift new file mode 100644 index 0000000..246a173 --- /dev/null +++ b/Sources/ClapCLI/Commands/TagCommand.swift @@ -0,0 +1,114 @@ +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, let id = Int64(rest[0]) else { + CLI.usageError("clap tag add requires and ", usage: usage) + } + 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, let id = Int64(rest[0]) else { + CLI.usageError("clap tag remove requires and ", usage: usage) + } + 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, let id = Int64(rest[0]) else { + CLI.usageError("clap tag set requires and at least one ", usage: usage) + } + 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 = Int64(first) { + await listTagsForEntry(id: id, context: context) + } else { + await listAllTags(context: context) + } + + default: + if let id = Int64(subcommand) { + await listTagsForEntry(id: id, context: context) + } else { + CLI.usageError("unknown tag subcommand '\(subcommand)'", usage: usage) + } + } + } + + 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/Main.swift b/Sources/ClapCLI/Main.swift index 2f16953..a41eb92 100644 --- a/Sources/ClapCLI/Main.swift +++ b/Sources/ClapCLI/Main.swift @@ -8,7 +8,7 @@ import ClapCore /// extracted before command dispatch. @main struct ClapMain { - static let version = "0.1.1" + static let version = "0.2.0" static func main() async { var args = Array(CommandLine.arguments.dropFirst()) @@ -42,6 +42,10 @@ struct ClapMain { await PinCommand.run(args, pinned: true, context: context) case "unpin": await PinCommand.run(args, pinned: false, context: context) + case "tag": + await TagCommand.run(args, context: context) + case "tags": + await TagCommand.run(["list"] + args, context: context) case "clear": await ClearCommand.run(args, context: context) case "stats": @@ -110,6 +114,8 @@ enum HelpText { 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] diff --git a/Sources/ClapCore/ClipboardStore.swift b/Sources/ClapCore/ClipboardStore.swift index 7a61a49..105e605 100644 --- a/Sources/ClapCore/ClipboardStore.swift +++ b/Sources/ClapCore/ClipboardStore.swift @@ -460,6 +460,77 @@ public actor ClipboardStore { 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 { + let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + .lowercased() + guard !tag.isEmpty else { return false } + let now = Date().timeIntervalSince1970 + try db.run(""" + INSERT OR IGNORE INTO entry_tags (entry_id, tag, created_at) + VALUES (?, ?, ?) + """, [.int(entryID), .text(tag), .double(now)]) + return db.changes > 0 + } + + /// Removes a tag from an entry. + @discardableResult + public func removeTag(_ rawTag: String, entryID: Int64) throws -> Bool { + let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + .lowercased() + guard !tag.isEmpty 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 { + let cleaned = tags.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + .lowercased() + }.filter { !$0.isEmpty } + var seen = Set() + var unique: [String] = [] + for t in cleaned { + if !seen.contains(t) { + seen.insert(t) + unique.append(t) + } + } + let now = Date().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 @@ -840,10 +911,12 @@ public actor ClipboardStore { // 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 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, (SELECT GROUP_CONCAT(tag, '|||') FROM entry_tags WHERE entry_id = entries.id) AS tags" static func rowToEntry(_ stmt: Statement) -> ClipboardEntry { - ClipboardEntry( + let tagStr = stmt.text(14) + let tags = tagStr?.components(separatedBy: "|||").filter { !$0.isEmpty } ?? [] + return ClipboardEntry( id: stmt.int64(0), type: EntryType(rawValue: stmt.text(1) ?? "") ?? .text, content: stmt.text(2), @@ -857,7 +930,8 @@ 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 ) } @@ -950,6 +1024,10 @@ public actor ClipboardStore { } if query.pinnedOnly { conditions.append("is_pinned = 1") } if query.favoriteOnly { conditions.append("is_favorite = 1") } + if let tag = query.tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")), !tag.isEmpty { + 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 ?" @@ -969,10 +1047,12 @@ public actor ClipboardStore { 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: ", ") + let prefixedColumns = """ + e.id, e.type, e.content, e.image_path, e.image_format, e.content_hash, + e.created_at, e.last_used_at, e.size_bytes, e.is_pinned, e.is_favorite, + e.use_count, e.source_app, e.shortcut, + (SELECT GROUP_CONCAT(tag, '|||') FROM entry_tags WHERE entry_id = e.id) AS tags + """ var sql = """ SELECT \(prefixedColumns) FROM entries e JOIN entries_fts ON entries_fts.rowid = e.id @@ -985,6 +1065,10 @@ public actor ClipboardStore { } if query.pinnedOnly { sql += " AND e.is_pinned = 1" } if query.favoriteOnly { sql += " AND e.is_favorite = 1" } + if let tag = query.tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")), !tag.isEmpty { + 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)))) @@ -1001,21 +1085,28 @@ public actor ClipboardStore { /// 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, + private 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 = Date().addingTimeInterval(Self.regexScanTimeBudget) + let cleanedTag = tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")) 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')" + var binds: [SQLValue] = [] if let only = contentType { sql += " AND type = '\(only.rawValue)'" } if pinnedOnly { sql += " AND is_pinned = 1" } if favoriteOnly { sql += " AND is_favorite = 1" } + if let cleanedTag, !cleanedTag.isEmpty { + 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 ?" - let batch = try db.query(sql, [.int(Int64(Self.regexScanBatchSize)), .int(Int64(dbOffset))], - Self.rowToEntry) + binds.append(.int(Int64(Self.regexScanBatchSize))) + binds.append(.int(Int64(dbOffset))) + let batch = try db.query(sql, binds, Self.rowToEntry) if batch.isEmpty { return } for entry in batch { scanned += 1 @@ -1038,7 +1129,7 @@ public actor ClipboardStore { 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 + 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 diff --git a/Sources/ClapCore/Database.swift b/Sources/ClapCore/Database.swift index 49c2aa1..cd2d773 100644 --- a/Sources/ClapCore/Database.swift +++ b/Sources/ClapCore/Database.swift @@ -230,9 +230,17 @@ final class Database { key TEXT PRIMARY KEY, 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); """ - /// Idempotent schema creation; sets user_version = 1. + /// Idempotent schema creation; sets user_version = 2. func migrate() throws { try transaction { let columns = try query("PRAGMA table_info(entries)", [], { row in row.text(1) ?? "" }) @@ -247,7 +255,17 @@ final class Database { try exec(Self.schemaSQL) try exec("CREATE INDEX IF NOT EXISTS idx_entries_fav ON entries(is_favorite, last_used_at DESC)") try exec("CREATE INDEX IF NOT EXISTS idx_entries_shortcut ON entries(shortcut)") - try exec("PRAGMA user_version = 1") + try exec(""" + 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) + ) + """) + try exec("CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag, created_at DESC)") + try exec("CREATE INDEX IF NOT EXISTS idx_entry_tags_entry ON entry_tags(entry_id)") + try exec("PRAGMA user_version = 2") } } } 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/Tests/ClapCoreTests/StoreTests.swift b/Tests/ClapCoreTests/StoreTests.swift index bae6dd9..7ad381c 100644 --- a/Tests/ClapCoreTests/StoreTests.swift +++ b/Tests/ClapCoreTests/StoreTests.swift @@ -806,4 +806,66 @@ struct ShellHistoryStoreTests { #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) + } + } } From 51ce2ae8a2202eecfca387681511ee815a2296fa Mon Sep 17 00:00:00 2001 From: spongycode Date: Fri, 21 Aug 2026 19:21:08 +0530 Subject: [PATCH 2/7] code refactor and cleanup --- .github/workflows/ci.yml | 29 + .swiftlint.yml | 69 + ARCHITECTURE.md | 101 +- Package.swift | 25 +- Sources/ClapApp/AppConstants.swift | 42 + Sources/ClapApp/AppDelegate.swift | 18 +- Sources/ClapApp/AppState+Pasteboard.swift | 120 ++ Sources/ClapApp/ContentView.swift | 116 +- Sources/ClapApp/HotKey.swift | 25 +- Sources/ClapApp/MenuBar.swift | 14 +- Sources/ClapApp/Panel.swift | 97 +- Sources/ClapApp/PasteboardMonitor.swift | 9 +- Sources/ClapApp/PreviewPanel.swift | 890 +++++++------ Sources/ClapApp/RowViews.swift | 675 +++------- .../ClapApp/SettingsView+Persistence.swift | 89 ++ Sources/ClapApp/SettingsView.swift | 134 +- Sources/ClapApp/ShellHistoryMonitor.swift | 32 +- Sources/ClapApp/SnippetEditorWindow.swift | 40 +- Sources/ClapApp/SnippetExpander.swift | 14 +- Sources/ClapApp/TagEditorWindow.swift | 41 +- Sources/ClapApp/UtilityWindow.swift | 50 + Sources/ClapApp/ViewModel.swift | 310 ++--- Sources/ClapApp/Workers.swift | 29 +- Sources/ClapCLI/Main.swift | 131 +- .../{ClapCLI => ClapCLIKit}/CLISupport.swift | 25 +- Sources/ClapCLIKit/ClapCLI.swift | 122 ++ .../Commands/CaptureCommand.swift | 0 .../Commands/ClearCommand.swift | 0 .../Commands/ConfigCommand.swift | 38 +- .../Commands/CopyCommand.swift | 9 +- .../Commands/DeleteCommand.swift | 0 .../Commands/DoctorCommand.swift | 0 .../Commands/GetCommand.swift | 18 +- .../Commands/ImportCommand.swift | 113 +- .../Commands/ListCommand.swift | 2 +- .../Commands/MaintainCommand.swift | 0 .../Commands/OpenCommand.swift | 0 .../Commands/PauseCommand.swift | 0 .../Commands/PinCommand.swift | 0 .../Commands/SearchCommand.swift | 0 .../Commands/StatsCommand.swift | 8 +- .../Commands/TagCommand.swift | 26 +- .../OutputFormatter.swift | 49 +- Sources/ClapCore/ClipboardStore+Capture.swift | 257 ++++ .../ClapCore/ClipboardStore+Diagnostics.swift | 187 +++ .../ClapCore/ClipboardStore+Maintenance.swift | 201 +++ .../ClapCore/ClipboardStore+Mutations.swift | 193 +++ Sources/ClapCore/ClipboardStore+Query.swift | 172 +++ Sources/ClapCore/ClipboardStore.swift | 1144 ++--------------- Sources/ClapCore/CoreConstants.swift | 41 + Sources/ClapCore/Database.swift | 31 +- Sources/ClapCore/Helpers.swift | 67 +- Sources/ClapCore/IPCNotifications.swift | 13 + Sources/ClapCore/OCREngine.swift | 44 + Sources/ClapCore/OCRScanner.swift | 52 - Sources/ClapCore/TextAnalysis.swift | 426 ++++++ Tests/ClapAppTests/AppStateTests.swift | 79 ++ Tests/ClapCLITests/CLITests.swift | 98 ++ Tests/ClapCoreTests/ImageTests.swift | 9 +- Tests/ClapCoreTests/OCRSeamTests.swift | 68 + .../ClapCoreTests/SearchQueryParseTests.swift | 4 +- Tests/ClapCoreTests/StoreTests.swift | 84 +- Tests/ClapCoreTests/TestSupport.swift | 23 +- Tests/ClapCoreTests/TextAnalysisTests.swift | 153 +++ 64 files changed, 4019 insertions(+), 2837 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swiftlint.yml create mode 100644 Sources/ClapApp/AppConstants.swift create mode 100644 Sources/ClapApp/AppState+Pasteboard.swift create mode 100644 Sources/ClapApp/SettingsView+Persistence.swift create mode 100644 Sources/ClapApp/UtilityWindow.swift rename Sources/{ClapCLI => ClapCLIKit}/CLISupport.swift (88%) create mode 100644 Sources/ClapCLIKit/ClapCLI.swift rename Sources/{ClapCLI => ClapCLIKit}/Commands/CaptureCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/ClearCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/ConfigCommand.swift (78%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/CopyCommand.swift (89%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/DeleteCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/DoctorCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/GetCommand.swift (80%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/ImportCommand.swift (82%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/ListCommand.swift (98%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/MaintainCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/OpenCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/PauseCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/PinCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/SearchCommand.swift (100%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/StatsCommand.swift (92%) rename Sources/{ClapCLI => ClapCLIKit}/Commands/TagCommand.swift (82%) rename Sources/{ClapCLI => ClapCLIKit}/OutputFormatter.swift (73%) create mode 100644 Sources/ClapCore/ClipboardStore+Capture.swift create mode 100644 Sources/ClapCore/ClipboardStore+Diagnostics.swift create mode 100644 Sources/ClapCore/ClipboardStore+Maintenance.swift create mode 100644 Sources/ClapCore/ClipboardStore+Mutations.swift create mode 100644 Sources/ClapCore/ClipboardStore+Query.swift create mode 100644 Sources/ClapCore/CoreConstants.swift create mode 100644 Sources/ClapCore/IPCNotifications.swift create mode 100644 Sources/ClapCore/OCREngine.swift delete mode 100644 Sources/ClapCore/OCRScanner.swift create mode 100644 Sources/ClapCore/TextAnalysis.swift create mode 100644 Tests/ClapAppTests/AppStateTests.swift create mode 100644 Tests/ClapCLITests/CLITests.swift create mode 100644 Tests/ClapCoreTests/OCRSeamTests.swift create mode 100644 Tests/ClapCoreTests/TextAnalysisTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c78720 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main, development] + pull_request: + +jobs: + test: + name: Build, Test & Lint (macOS) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - 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 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..1e4fd2b 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: @@ -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/Sources/ClapApp/AppConstants.swift b/Sources/ClapApp/AppConstants.swift new file mode 100644 index 0000000..450887c --- /dev/null +++ b/Sources/ClapApp/AppConstants.swift @@ -0,0 +1,42 @@ +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 + static let searchField: Double = 0.05 + static let rowSelected: Double = 0.36 + static let pillSelectedCount: Double = 0.20 + } + enum Stroke { + static let hairline: Double = 0.08 + static let panelBorder: Double = 0.12 + static let rowSelectedBorder: Double = 0.45 + static let swatch: Double = 0.20 + } + 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..df8f6ca 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) { diff --git a/Sources/ClapApp/AppState+Pasteboard.swift b/Sources/ClapApp/AppState+Pasteboard.swift new file mode 100644 index 0000000..0a01a2e --- /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 + } + + /// 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. + 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 b37b074..f0859e2 100644 --- a/Sources/ClapApp/ContentView.swift +++ b/Sources/ClapApp/ContentView.swift @@ -21,7 +21,7 @@ struct ContentView: View { .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) ) .onAppear { searchFocused = true } .onChange(of: state.searchFocusToken) { _, _ in searchFocused = true } @@ -39,10 +39,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 +56,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) ) ) @@ -77,6 +80,38 @@ 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 @@ -89,7 +124,7 @@ struct ContentView: View { count: state.selectedTag == nil ? (state.entries.count + state.pinned.count) : nil, isSelected: state.selectedTag == nil ) { - state.selectedTag = nil + state.selectTag(nil) } // Tag Pills @@ -99,7 +134,7 @@ struct ContentView: View { count: item.count, isSelected: state.selectedTag?.lowercased() == item.tag.lowercased() ) { - state.selectedTag = item.tag + state.selectTag(item.tag) } } } @@ -113,7 +148,8 @@ struct ContentView: View { Button(action: action) { HStack(spacing: 4) { Text(title) - .font(.system(size: 11.5, weight: isSelected ? .bold : .medium, design: title.hasPrefix("#") ? .monospaced : .default)) + .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)) @@ -122,7 +158,7 @@ struct ContentView: View { .padding(.vertical, 1) .background( Capsule() - .fill(isSelected ? Color.white.opacity(0.2) : Color.primary.opacity(0.06)) + .fill(isSelected ? Color.white.opacity(0.2) : Color.primary.opacity(AppAlpha.Fill.soft)) ) } } @@ -131,7 +167,7 @@ struct ContentView: View { .foregroundStyle(isSelected ? Color.white : Color.primary) .background( Capsule() - .fill(isSelected ? Color.accentColor : Color.primary.opacity(0.06)) + .fill(isSelected ? Color.accentColor : Color.primary.opacity(AppAlpha.Fill.soft)) ) } .buttonStyle(.plain) @@ -150,13 +186,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") } } @@ -164,22 +202,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 + state.selectTab(.media) } TabButton(icon: "terminal", tab: .shell, currentTab: state.tab, shortcut: "⌘3", label: "Shell") { - state.tab = .shell + state.selectTab(.shell) } TabButton(icon: "heart.fill", tab: .favs, currentTab: state.tab, shortcut: "⌘4", label: "Favs") { - state.tab = .favs + state.selectTab(.favs) } } .padding(2.5) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(0.06)) + .fill(Color.primary.opacity(AppAlpha.Fill.soft)) ) } @@ -209,7 +247,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) ) @@ -218,12 +257,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 { @@ -246,7 +292,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( @@ -264,6 +312,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") } } @@ -367,23 +419,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/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..8d13539 100644 --- a/Sources/ClapApp/Panel.swift +++ b/Sources/ClapApp/Panel.swift @@ -1,4 +1,5 @@ import AppKit +import ClapCore import SwiftUI /// Borderless nonactivating floating panel hosting the SwiftUI UI. @@ -19,11 +20,12 @@ 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" + private static let frameConfigKey = ConfigKey.uiPanelFrame private let panel: ClapPanel private let appState: AppState private var keyMonitor: Any? + private var mouseMoveMonitor: Any? private var previewController: PreviewController? private var previousApp: NSRunningApplication? @@ -56,6 +58,9 @@ final class PanelController: NSObject, NSWindowDelegate { 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)) @@ -74,6 +79,7 @@ final class PanelController: NSObject, NSWindowDelegate { deinit { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + if let mouseMoveMonitor { NSEvent.removeMonitor(mouseMoveMonitor) } } var isVisible: Bool { panel.isVisible } @@ -107,6 +113,7 @@ final class PanelController: NSObject, NSWindowDelegate { } suppressFrameSave = false panel.makeKeyAndOrderFront(nil) + installMouseMoveMonitor() // Focus the search field once the panel is actually key. Task { @MainActor [appState] in appState.searchFocusToken += 1 @@ -115,11 +122,12 @@ final class PanelController: NSObject, NSWindowDelegate { func hide(reactivatePreviousApp: Bool = false) { guard panel.isVisible else { return } + removeMouseMoveMonitor() previewController?.hide() panel.orderOut(nil) if reactivatePreviousApp { if let previousApp, !previousApp.isTerminated { - previousApp.activate(options: .activateIgnoringOtherApps) + previousApp.activate() } else { NSApp.hide(nil) } @@ -161,7 +169,7 @@ final class PanelController: NSObject, NSWindowDelegate { // 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)) @@ -194,6 +202,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 +237,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 +271,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(.media) + case "3": + appState.selectTab(.shell) + case "4": + appState.selectTab(.favs) + 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/PreviewPanel.swift b/Sources/ClapApp/PreviewPanel.swift index 98f4f90..f81e37f 100644 --- a/Sources/ClapApp/PreviewPanel.swift +++ b/Sources/ClapApp/PreviewPanel.swift @@ -58,7 +58,8 @@ final class PreviewController { hide() return } - let stateKey = "\(entry.id)-\(entry.isPinned)-\(entry.isFavorite)-\(entry.useCount)-\(entry.lastUsedAt.timeIntervalSince1970)-\(appState.trimmedQuery)-\(appState.regexMode)" + 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( @@ -115,12 +116,36 @@ final class PreviewController { // 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? + + static let empty = ParsedEntryContent() + + static func parse(_ content: String?) -> ParsedEntryContent { + guard let content 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) { @@ -134,321 +159,46 @@ struct PreviewView: View { .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) ) + .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() - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(0.04)) - ) + if let color = parsed.color { + ColorCardView(color: color, source: entry.content ?? "") } - - 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) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.blue.opacity(0.06)) - ) + if let decoded = parsed.base64Decoded { + DecodedCardView(icon: "doc.text.magnifyingglass", + tint: .blue, + title: "Base64 Decoded", + decoded: decoded) { state.copyTransformedText(decoded) } } - - 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) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.teal.opacity(0.06)) - ) + if let decoded = parsed.urlDecoded { + DecodedCardView(icon: "link", + tint: .teal, + title: "URL Decoded", + decoded: decoded) { state.copyTransformedText(decoded) } } - - 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)) - ) - } - .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 jwt = parsed.jwt { + JWTCardView(jwt: jwt) { text in state.copyTransformedText(text) } } - - 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)) - ) - } - .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) - ) - ) + if let epoch = parsed.epoch { + EpochCardView(epoch: epoch) { text in state.copyTransformedText(text) } } - Text(highlightedDisplayedText) .font(.system(size: 13, design: .monospaced)) .textSelection(.enabled) @@ -457,79 +207,14 @@ struct PreviewView: View { .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) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.primary.opacity(0.04)) - ) - } - .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 + 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 +231,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,7 +256,7 @@ 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 { @@ -698,15 +317,7 @@ struct PreviewView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { ForEach(entry.tags, id: \.self) { tag in - Text("#\(tag)") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(.blue) - .padding(.horizontal, 5) - .padding(.vertical, 1.5) - .background( - Capsule() - .fill(Color.blue.opacity(0.12)) - ) + TagPillView(tag: tag) } } } @@ -731,6 +342,8 @@ struct PreviewView: View { } } + // MARK: Helpers + private var highlightedDisplayedText: AttributedString { SearchHighlighter.highlight( text: displayedText, @@ -748,7 +361,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 { @@ -774,7 +388,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 } } @@ -793,3 +407,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 a79676f..989c52f 100644 --- a/Sources/ClapApp/RowViews.swift +++ b/Sources/ClapApp/RowViews.swift @@ -12,35 +12,7 @@ struct EntryRow: View { 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) - } + leadingIcon Text(highlightedPreview) .lineLimit(1) .truncationMode(.tail) @@ -62,19 +34,7 @@ struct EntryRow: View { ) } ForEach(entry.tags.prefix(2), id: \.self) { tag in - 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) - ) - ) + TagPillView(tag: tag) } if entry.isPinned { Image(systemName: "pin.fill") @@ -86,7 +46,7 @@ struct EntryRow: View { .font(.system(size: 12.5)) .foregroundStyle(.red) } - Text(RelativeTime.string(for: entry.lastUsedAt)) + Text(TextSummaries.relativeTime(entry.lastUsedAt, now: Date())) .font(.system(size: 11.5)) .foregroundStyle(.secondary) .monospacedDigit() @@ -106,92 +66,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) } - Button(entry.tags.isEmpty ? "Manage Tags…" : "Manage Tags (\(entry.tags.map { "#\($0)" }.joined(separator: ", ")))…") { - state.promptManageTags(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 parsed = ColorParser.parse(entry.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 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) + } + } + private var highlightedPreview: AttributedString { SearchHighlighter.highlight( text: preview, @@ -203,18 +123,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))" @@ -222,409 +134,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 - } - } +// MARK: - Shared row context menu - 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() - - 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() - } + 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 if ch.isNumber { - let prevIsLetter = (i > 0 && chars[i-1].isLetter) - if prevIsLetter { - flush() - } - 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 } - - let date: Date - let unit: String +// MARK: - Tag pill - // 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 - } +struct TagPillView: View { + let tag: String - 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 } @@ -638,7 +271,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 } } @@ -648,7 +281,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 { diff --git a/Sources/ClapApp/SettingsView+Persistence.swift b/Sources/ClapApp/SettingsView+Persistence.swift new file mode 100644 index 0000000..cf937cd --- /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 { + _ = try? 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 6cdccd0..6d6eed2 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 @@ -61,17 +59,17 @@ struct SettingsView: View { 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") } + .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 +87,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) @@ -118,6 +118,16 @@ struct SettingsView: View { .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 +317,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 +360,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 +371,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 +416,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 +424,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/SnippetEditorWindow.swift b/Sources/ClapApp/SnippetEditorWindow.swift index e2b9267..4208222 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) ) ) } 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 index 1aa2611..9af67e8 100644 --- a/Sources/ClapApp/TagEditorWindow.swift +++ b/Sources/ClapApp/TagEditorWindow.swift @@ -4,17 +4,17 @@ import ClapCore /// Manages the dedicated titled window for managing tags on an entry. @MainActor -final class TagWindowController: NSObject, NSWindowDelegate { +final class TagWindowController: UtilityWindowController { static let shared = TagWindowController() - private var window: NSWindow? - private var currentEntry: ClipboardEntry? + 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) { - currentEntry = entry - let allTags = state.availableTags.map(\.tag) - let view = TagEditorView( + show(rootView: TagEditorView( entry: entry, suggestedTags: allTags, onSave: { [weak self] newTags in @@ -24,29 +24,7 @@ final class TagWindowController: NSObject, NSWindowDelegate { onCancel: { [weak self] in self?.close() } - ) - - if window == nil { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 440, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = "Manage Tags & Pinboards" - 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() + )) } } @@ -152,10 +130,11 @@ struct TagEditorView: View { .padding(.vertical, 3) .background( Capsule() - .fill(Color.primary.opacity(0.06)) + .fill(Color.primary.opacity(AppAlpha.Fill.soft)) .overlay( Capsule() - .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + .strokeBorder(Color.primary.opacity(AppAlpha.Stroke.panelBorder), + lineWidth: 0.5) ) ) } diff --git a/Sources/ClapApp/UtilityWindow.swift b/Sources/ClapApp/UtilityWindow.swift new file mode 100644 index 0000000..7bebff9 --- /dev/null +++ b/Sources/ClapApp/UtilityWindow.swift @@ -0,0 +1,50 @@ +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 + window.center() + self.window = window + } + window?.contentView = NSHostingView(rootView: rootView) + 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 fd1836a..aa926aa 100644 --- a/Sources/ClapApp/ViewModel.swift +++ b/Sources/ClapApp/ViewModel.swift @@ -31,54 +31,69 @@ final class AppState: ObservableObject { /// 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 var selectedTag: String? = nil { - didSet { - guard oldValue != selectedTag else { return } - selectedID = nil - reload() - } - } + @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 @@ -86,6 +101,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) } @@ -100,13 +136,14 @@ 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 rawQuery = "" - suppressSearchTrigger = false selectedID = nil + pointerArmed = false + pendingPointerEntryID = nil reload() } @@ -115,7 +152,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() } @@ -144,6 +181,25 @@ final class AppState: ObservableObject { 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 @@ -159,21 +215,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 - if let currentTag { - fetched = try await self.store.search(SearchQuery(tag: currentTag, limit: Self.pageSize, offset: 0)) - } else { - 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 @@ -223,18 +271,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 - if let currentTag { - fetched = try await self.store.search(SearchQuery(tag: currentTag, limit: Self.pageSize, offset: offset)) - } else { - 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 @@ -255,9 +294,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) { @@ -279,7 +351,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() } @@ -289,7 +361,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() } @@ -304,7 +376,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() @@ -333,7 +405,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() @@ -362,7 +434,7 @@ final class AppState: ObservableObject { func addTag(_ tag: String, to entry: ClipboardEntry) { Task { @MainActor [weak self] in guard let self else { return } - _ = try? await self.store.addTag(tag, entryID: entry.id) + await self.perform("Add tag") { try await self.store.addTag(tag, entryID: entry.id) } IPC.post(.storeChanged) self.reload() self.refreshTags() @@ -372,7 +444,7 @@ final class AppState: ObservableObject { func removeTag(_ tag: String, from entry: ClipboardEntry) { Task { @MainActor [weak self] in guard let self else { return } - _ = try? await self.store.removeTag(tag, entryID: entry.id) + await self.perform("Remove tag") { try await self.store.removeTag(tag, entryID: entry.id) } IPC.post(.storeChanged) self.reload() self.refreshTags() @@ -382,112 +454,10 @@ final class AppState: ObservableObject { func setTags(_ tags: [String], for entry: ClipboardEntry) { Task { @MainActor [weak self] in guard let self else { return } - try? await self.store.setTags(tags, entryID: entry.id) + await self.perform("Save tags") { try await self.store.setTags(tags, entryID: entry.id) } IPC.post(.storeChanged) self.reload() self.refreshTags() } } - - // 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 } - 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) - } - 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() - } - } - } - - /// 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) - _ = try? await self.store.captureText(text, sourceApp: "clap") - 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() - } - } - } - - // 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 - } } 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 a41eb92..c5ef5e3 100644 --- a/Sources/ClapCLI/Main.swift +++ b/Sources/ClapCLI/Main.swift @@ -1,135 +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.2.0" - 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 "tag": - await TagCommand.run(args, context: context) - case "tags": - await TagCommand.run(["list"] + args, 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 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/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 98% rename from Sources/ClapCLI/Commands/ListCommand.swift rename to Sources/ClapCLIKit/Commands/ListCommand.swift index 6bb51b3..618a772 100644 --- a/Sources/ClapCLI/Commands/ListCommand.swift +++ b/Sources/ClapCLIKit/Commands/ListCommand.swift @@ -43,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 100% rename from Sources/ClapCLI/Commands/SearchCommand.swift rename to Sources/ClapCLIKit/Commands/SearchCommand.swift 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/ClapCLI/Commands/TagCommand.swift b/Sources/ClapCLIKit/Commands/TagCommand.swift similarity index 82% rename from Sources/ClapCLI/Commands/TagCommand.swift rename to Sources/ClapCLIKit/Commands/TagCommand.swift index 246a173..2869779 100644 --- a/Sources/ClapCLI/Commands/TagCommand.swift +++ b/Sources/ClapCLIKit/Commands/TagCommand.swift @@ -27,9 +27,10 @@ enum TagCommand { switch subcommand { case "add": - guard rest.count >= 2, let id = Int64(rest[0]) else { + 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() @@ -42,9 +43,10 @@ enum TagCommand { } case "remove", "rm": - guard rest.count >= 2, let id = Int64(rest[0]) else { + 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() @@ -57,9 +59,10 @@ enum TagCommand { } case "set": - guard rest.count >= 2, let id = Int64(rest[0]) else { + 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() @@ -68,19 +71,24 @@ enum TagCommand { } case "list", "ls": - if let first = rest.first, let id = Int64(first) { + if let first = rest.first { + let id = validatedID(first, action: "list") await listTagsForEntry(id: id, context: context) } else { await listAllTags(context: context) } default: - if let id = Int64(subcommand) { - await listTagsForEntry(id: id, context: context) - } else { - CLI.usageError("unknown tag subcommand '\(subcommand)'", usage: usage) - } + 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 { 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..e50dd29 --- /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 + // (Maccy-style). 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 105e605..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,865 +68,31 @@ 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() - - 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 - } - - // 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 { - let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "#")) - .lowercased() - guard !tag.isEmpty else { return false } - let now = Date().timeIntervalSince1970 - try db.run(""" - INSERT OR IGNORE INTO entry_tags (entry_id, tag, created_at) - VALUES (?, ?, ?) - """, [.int(entryID), .text(tag), .double(now)]) - return db.changes > 0 - } - - /// Removes a tag from an entry. - @discardableResult - public func removeTag(_ rawTag: String, entryID: Int64) throws -> Bool { - let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "#")) - .lowercased() - guard !tag.isEmpty 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 { - let cleaned = tags.map { - $0.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "#")) - .lowercased() - }.filter { !$0.isEmpty } - var seen = Set() - var unique: [String] = [] - for t in cleaned { - if !seen.contains(t) { - seen.insert(t) - unique.append(t) - } - } - let now = Date().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 - } - 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) - } + // MARK: - Shared row plumbing - /// 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")) - } + static let entryColumns: String = + (entryColumnNames + [tagsSubquery]).joined(separator: ", ") - // 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)")) - } - - 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, (SELECT GROUP_CONCAT(tag, '|||') FROM entry_tags WHERE entry_id = entries.id) AS tags" - static func rowToEntry(_ stmt: Statement) -> ClipboardEntry { let tagStr = stmt.text(14) - let tags = tagStr?.components(separatedBy: "|||").filter { !$0.isEmpty } ?? [] + let tags = tagStr?.components(separatedBy: CoreConstants.tagConcatSeparator) + .filter { !$0.isEmpty } ?? [] return ClipboardEntry( id: stmt.int64(0), type: EntryType(rawValue: stmt.text(1) ?? "") ?? .text, @@ -935,232 +112,101 @@ public actor ClipboardStore { ) } - 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) } - private func configInt(_ key: String) throws -> Int { + /// 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 + } + + 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") } - if let tag = query.tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")), !tag.isEmpty { - 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: " ") - } - - private func ftsSearch(_ tokens: [QueryTokenizer.Token], query: SearchQuery) throws -> [ClipboardEntry] { - let match = Self.ftsMatchExpression(tokens) - let prefixedColumns = """ - e.id, e.type, e.content, e.image_path, e.image_format, e.content_hash, - e.created_at, e.last_used_at, e.size_bytes, e.is_pinned, e.is_favorite, - e.use_count, e.source_app, e.shortcut, - (SELECT GROUP_CONCAT(tag, '|||') FROM entry_tags WHERE entry_id = e.id) AS tags - """ - 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" } - if let tag = query.tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")), !tag.isEmpty { - 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 - 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, tag: String? = nil, contentType: EntryType? = nil, - _ visit: (ClipboardEntry) throws -> Bool) throws { - var scanned = 0 - var dbOffset = 0 - let deadline = Date().addingTimeInterval(Self.regexScanTimeBudget) - let cleanedTag = tag?.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "#")) - 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')" - var binds: [SQLValue] = [] - if let only = contentType { sql += " AND type = '\(only.rawValue)'" } - if pinnedOnly { sql += " AND is_pinned = 1" } - if favoriteOnly { sql += " AND is_favorite = 1" } - if let cleanedTag, !cleanedTag.isEmpty { - 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(Self.regexScanBatchSize))) - 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 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, 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 } - } + for entry in entries where entry.type == .image { + if let path = entry.imagePath { + remove(imagesDir.appendingPathComponent(path), label: "image") } - return true + remove(thumbnailsDir.appendingPathComponent("\(entry.contentHash).png"), label: "thumbnail") } - 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)]) - } - } -} - -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 { + var collapsed = "" + var previousWasSpace = true + for scalar in s.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 + } + } + let trimmed = collapsed.trimmingCharacters(in: .whitespaces) + guard trimmed.count > maxChars else { return trimmed } + let cutoff = trimmed.index(trimmed.startIndex, offsetBy: 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)" } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} + +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 +95,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 +156,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/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 7ad381c..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,13 +812,13 @@ 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) } } 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..b390685 --- /dev/null +++ b/Tests/ClapCoreTests/TextAnalysisTests.swift @@ -0,0 +1,153 @@ +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") + } + + // 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) + } +} From 80340081c6c8726eff86bc3b9c8126647002e4c8 Mon Sep 17 00:00:00 2001 From: spongycode Date: Fri, 21 Aug 2026 19:32:50 +0530 Subject: [PATCH 3/7] move media tab to last --- ARCHITECTURE.md | 2 +- README.md | 4 ++-- Sources/ClapApp/ContentView.swift | 10 +++++----- Sources/ClapApp/Panel.swift | 6 +++--- install.sh | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1e4fd2b..9d3ad94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -293,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 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/Sources/ClapApp/ContentView.swift b/Sources/ClapApp/ContentView.swift index f0859e2..71594a3 100644 --- a/Sources/ClapApp/ContentView.swift +++ b/Sources/ClapApp/ContentView.swift @@ -204,15 +204,15 @@ struct ContentView: View { TabButton(icon: "doc.on.clipboard", tab: .classic, currentTab: state.tab, shortcut: "⌘1", label: "Classic") { state.selectTab(.classic) } - TabButton(icon: "photo", tab: .media, currentTab: state.tab, shortcut: "⌘2", label: "Media") { - state.selectTab(.media) - } - TabButton(icon: "terminal", tab: .shell, currentTab: state.tab, shortcut: "⌘3", label: "Shell") { + TabButton(icon: "terminal", tab: .shell, currentTab: state.tab, shortcut: "⌘2", label: "Shell") { state.selectTab(.shell) } - TabButton(icon: "heart.fill", tab: .favs, currentTab: state.tab, shortcut: "⌘4", label: "Favs") { + TabButton(icon: "heart.fill", tab: .favs, currentTab: state.tab, shortcut: "⌘3", label: "Favs") { state.selectTab(.favs) } + TabButton(icon: "photo", tab: .media, currentTab: state.tab, shortcut: "⌘4", label: "Media") { + state.selectTab(.media) + } } .padding(2.5) .background( diff --git a/Sources/ClapApp/Panel.swift b/Sources/ClapApp/Panel.swift index 8d13539..e2246bf 100644 --- a/Sources/ClapApp/Panel.swift +++ b/Sources/ClapApp/Panel.swift @@ -281,11 +281,11 @@ final class PanelController: NSObject, NSWindowDelegate { case "1": appState.selectTab(.classic) case "2": - appState.selectTab(.media) - case "3": appState.selectTab(.shell) - case "4": + case "3": appState.selectTab(.favs) + case "4": + appState.selectTab(.media) case "p": appState.togglePinSelected() case "s", "b": 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 From d4d183d26b6ef362d4f23a36423980bc9170876d Mon Sep 17 00:00:00 2001 From: spongycode Date: Fri, 21 Aug 2026 19:47:24 +0530 Subject: [PATCH 4/7] CI: require macOS 15 runner for Swift 6 toolchain --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c78720..988780d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,19 @@ on: jobs: test: name: Build, Test & Lint (macOS) - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 + # Package.swift requires Swift tools 6.0; macos-15 images ship an + # Xcode 16 series default. Pin to the latest stable for reproducibility. - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.4' + xcode-version: latest_stable + + - name: Verify toolchain + run: swift --version - name: Build (warnings are errors) run: swift build -Xswiftc -warnings-as-errors From 70414cf3518d7d2ebdfad2839c2a8fb727df9bca Mon Sep 17 00:00:00 2001 From: spongycode Date: Sat, 22 Aug 2026 12:05:49 +0530 Subject: [PATCH 5/7] add liquid glass ui, slide-out preview panel, native window resizing, and text/time fixes --- .github/workflows/ci.yml | 5 +- Sources/ClapApp/AppConstants.swift | 5 - Sources/ClapApp/AppDelegate.swift | 1 - Sources/ClapApp/AppIconView.swift | 46 +++ Sources/ClapApp/AppState+Pasteboard.swift | 2 +- Sources/ClapApp/ContentView.swift | 30 +- Sources/ClapApp/EdgeResizeOverlay.swift | 255 +++++++++++++++ Sources/ClapApp/GlassEffectBackground.swift | 48 +++ Sources/ClapApp/Panel.swift | 73 +++-- Sources/ClapApp/Paster.swift | 2 +- Sources/ClapApp/PreviewPanel.swift | 305 ++++++++++-------- Sources/ClapApp/RowViews.swift | 92 +++--- .../ClapApp/SettingsView+Persistence.swift | 2 +- Sources/ClapApp/SettingsView.swift | 16 +- Sources/ClapApp/SlideoutController.swift | 172 ++++++++++ Sources/ClapApp/SlideoutView.swift | 154 +++++++++ Sources/ClapApp/SnippetEditorWindow.swift | 3 +- Sources/ClapApp/TagEditorWindow.swift | 2 +- Sources/ClapApp/UtilityWindow.swift | 19 +- Sources/ClapApp/ViewModel.swift | 3 + .../ClapCore/ClipboardStore+Diagnostics.swift | 2 +- Sources/ClapCore/Helpers.swift | 32 +- Tests/ClapCoreTests/TextAnalysisTests.swift | 3 + 23 files changed, 1029 insertions(+), 243 deletions(-) create mode 100644 Sources/ClapApp/AppIconView.swift create mode 100644 Sources/ClapApp/EdgeResizeOverlay.swift create mode 100644 Sources/ClapApp/GlassEffectBackground.swift create mode 100644 Sources/ClapApp/SlideoutController.swift create mode 100644 Sources/ClapApp/SlideoutView.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988780d..bca632e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,11 @@ on: jobs: test: name: Build, Test & Lint (macOS) - runs-on: macos-15 + # macOS 26 SDK is required to compile NSGlassEffectView (Liquid Glass). + runs-on: macos-26 steps: - uses: actions/checkout@v4 - # Package.swift requires Swift tools 6.0; macos-15 images ship an - # Xcode 16 series default. Pin to the latest stable for reproducibility. - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: diff --git a/Sources/ClapApp/AppConstants.swift b/Sources/ClapApp/AppConstants.swift index 450887c..9c7e2c7 100644 --- a/Sources/ClapApp/AppConstants.swift +++ b/Sources/ClapApp/AppConstants.swift @@ -25,15 +25,10 @@ enum AppAlpha { enum Fill { static let subtle: Double = 0.04 static let soft: Double = 0.06 - static let searchField: Double = 0.05 - static let rowSelected: Double = 0.36 - static let pillSelectedCount: Double = 0.20 } enum Stroke { static let hairline: Double = 0.08 static let panelBorder: Double = 0.12 - static let rowSelectedBorder: Double = 0.45 - static let swatch: Double = 0.20 } enum Hover { static let fill: Double = 0.09 diff --git a/Sources/ClapApp/AppDelegate.swift b/Sources/ClapApp/AppDelegate.swift index df8f6ca..e32602d 100644 --- a/Sources/ClapApp/AppDelegate.swift +++ b/Sources/ClapApp/AppDelegate.swift @@ -137,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 index 0a01a2e..1ee059d 100644 --- a/Sources/ClapApp/AppState+Pasteboard.swift +++ b/Sources/ClapApp/AppState+Pasteboard.swift @@ -66,7 +66,7 @@ extension AppState { return true } - /// Maccy-style paste-on-select: the panel never activated clap, so the + /// 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. diff --git a/Sources/ClapApp/ContentView.swift b/Sources/ClapApp/ContentView.swift index 71594a3..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(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) 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/Panel.swift b/Sources/ClapApp/Panel.swift index e2246bf..d55b320 100644 --- a/Sources/ClapApp/Panel.swift +++ b/Sources/ClapApp/Panel.swift @@ -1,6 +1,7 @@ import AppKit import ClapCore import SwiftUI +import Combine /// Borderless nonactivating floating panel hosting the SwiftUI UI. final class ClapPanel: NSPanel { @@ -18,15 +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) + 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 mouseMoveMonitor: Any? - private var previewController: PreviewController? + private var selectionCancellable: AnyCancellable? private var previousApp: NSRunningApplication? @@ -54,7 +55,7 @@ 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 @@ -64,9 +65,29 @@ final class PanelController: NSObject, NSWindowDelegate { 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 } @@ -99,19 +120,27 @@ 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. @@ -122,8 +151,9 @@ final class PanelController: NSObject, NSWindowDelegate { func hide(reactivatePreviousApp: Bool = false) { guard panel.isVisible else { return } + appState.slideout.cancelAutoOpen() + appState.slideout.closePreview(animated: false) removeMouseMoveMonitor() - previewController?.hide() panel.orderOut(nil) if reactivatePreviousApp { if let previousApp, !previousApp.isTerminated { @@ -143,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 @@ -164,7 +189,10 @@ 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() @@ -184,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 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 f81e37f..e8b572f 100644 --- a/Sources/ClapApp/PreviewPanel.swift +++ b/Sources/ClapApp/PreviewPanel.swift @@ -3,114 +3,121 @@ 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) } } @@ -125,10 +132,14 @@ private struct ParsedEntryContent { 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 else { return .empty } + guard let content, !content.isEmpty, content.count <= 20_000 else { return .empty } return ParsedEntryContent( color: ColorParser.parse(content), base64Decoded: TextTransformer.decodeBase64(content), @@ -155,12 +166,6 @@ 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(AppAlpha.Stroke.panelBorder), lineWidth: 1) - ) .task(id: entry.id) { parsed = await Task.detached(priority: .userInitiated) { ParsedEntryContent.parse(entry.content) @@ -176,35 +181,53 @@ struct PreviewView: View { @ViewBuilder private var contentSection: some View { if entry.type == .text || entry.type == .shell { - ScrollView([.vertical]) { - VStack(alignment: .leading, spacing: 12) { - if let color = parsed.color { - ColorCardView(color: color, source: entry.content ?? "") - } - if let decoded = parsed.base64Decoded { - DecodedCardView(icon: "doc.text.magnifyingglass", - tint: .blue, - title: "Base64 Decoded", - decoded: decoded) { state.copyTransformedText(decoded) } - } - if let decoded = parsed.urlDecoded { - DecodedCardView(icon: "link", - tint: .teal, - title: "URL Decoded", - decoded: decoded) { state.copyTransformedText(decoded) } - } - if let jwt = parsed.jwt { - JWTCardView(jwt: jwt) { text in state.copyTransformedText(text) } + VStack(alignment: .leading, spacing: 0) { + if parsed.hasCards { + VStack(alignment: .leading, spacing: 10) { + if let color = parsed.color { + ColorCardView(color: color, source: entry.content ?? "") + } + if let decoded = parsed.base64Decoded { + DecodedCardView(icon: "doc.text.magnifyingglass", + tint: .blue, + title: "Base64 Decoded", + decoded: decoded) { state.copyTransformedText(decoded) } + } + if let decoded = parsed.urlDecoded { + DecodedCardView(icon: "link", + tint: .teal, + title: "URL Decoded", + decoded: decoded) { state.copyTransformedText(decoded) } + } + if let jwt = parsed.jwt { + JWTCardView(jwt: jwt) { text in state.copyTransformedText(text) } + } + if let epoch = parsed.epoch { + EpochCardView(epoch: epoch) { text in state.copyTransformedText(text) } + } } - if let epoch = parsed.epoch { - EpochCardView(epoch: epoch) { text in state.copyTransformedText(text) } + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 6) + } + + 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(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } } - Text(highlightedDisplayedText) - .font(.system(size: 13, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) } - .padding(14) } } else { ImageContentView(entry: entry, image: image) { text in @@ -287,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)) @@ -306,9 +340,12 @@ 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 { diff --git a/Sources/ClapApp/RowViews.swift b/Sources/ClapApp/RowViews.swift index 989c52f..75adae7 100644 --- a/Sources/ClapApp/RowViews.swift +++ b/Sources/ClapApp/RowViews.swift @@ -11,47 +11,50 @@ struct EntryRow: View { private var isSelected: Bool { state.selectedID == entry.id } var body: some View { - HStack(spacing: 11) { + 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) - ) - ) - } - ForEach(entry.tags.prefix(2), id: \.self) { tag in - TagPillView(tag: tag) - } - 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(TextSummaries.relativeTime(entry.lastUsedAt, now: Date())) - .font(.system(size: 11.5)) - .foregroundStyle(.secondary) - .monospacedDigit() - .frame(width: 48, alignment: .trailing) - .lineLimit(1) } .padding(.horizontal, 11) .padding(.vertical, 7.5) @@ -89,7 +92,7 @@ struct EntryRow: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .frame(width: 20) - } else if let parsed = ColorParser.parse(entry.content) { + } 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)) @@ -99,12 +102,12 @@ struct EntryRow: View { .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 { + } 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 EpochData.parse(entry.content) != nil { + } 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) @@ -422,16 +425,3 @@ struct ThumbnailView: View { } } } - -/// 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 index cf937cd..b243c6d 100644 --- a/Sources/ClapApp/SettingsView+Persistence.swift +++ b/Sources/ClapApp/SettingsView+Persistence.swift @@ -25,7 +25,7 @@ extension SettingsView { private func enqueueSave(key: String, value: String) { let previous = saveTasks[key] saveTasks[key] = Task { - _ = try? await previous?.value + _ = await previous?.value guard !Task.isCancelled else { return } do { try await self.store.setConfig(key, value: value) diff --git a/Sources/ClapApp/SettingsView.swift b/Sources/ClapApp/SettingsView.swift index 6d6eed2..075a9d2 100644 --- a/Sources/ClapApp/SettingsView.swift +++ b/Sources/ClapApp/SettingsView.swift @@ -58,8 +58,20 @@ struct SettingsView: View { @State private var newExclusion = "" var body: some View { - formWithLimitHandlers - .onChange(of: shellEnabled) { _, value in save(ConfigKey.shellEnabled, 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) } 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 4208222..58d3b73 100644 --- a/Sources/ClapApp/SnippetEditorWindow.swift +++ b/Sources/ClapApp/SnippetEditorWindow.swift @@ -107,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/TagEditorWindow.swift b/Sources/ClapApp/TagEditorWindow.swift index 9af67e8..1e93dec 100644 --- a/Sources/ClapApp/TagEditorWindow.swift +++ b/Sources/ClapApp/TagEditorWindow.swift @@ -171,9 +171,9 @@ struct TagEditorView: View { } .padding(.horizontal, 18) .padding(.vertical, 12) - .background(Color.primary.opacity(0.02)) } .frame(width: 440) + .background(AdaptivePanelBackground().ignoresSafeArea()) .onAppear { tags = entry.tags isFocused = true diff --git a/Sources/ClapApp/UtilityWindow.swift b/Sources/ClapApp/UtilityWindow.swift index 7bebff9..5df04d8 100644 --- a/Sources/ClapApp/UtilityWindow.swift +++ b/Sources/ClapApp/UtilityWindow.swift @@ -29,10 +29,27 @@ class UtilityWindowController: NSObject, NSWindowDelegate { 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 } - window?.contentView = NSHostingView(rootView: rootView) + // 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) diff --git a/Sources/ClapApp/ViewModel.swift b/Sources/ClapApp/ViewModel.swift index aa926aa..f1e24a7 100644 --- a/Sources/ClapApp/ViewModel.swift +++ b/Sources/ClapApp/ViewModel.swift @@ -25,6 +25,7 @@ final class AppState: ObservableObject { let store: ClipboardStore let monitor: PasteboardMonitor + let slideout = SlideoutController() /// Set by PanelController — closes the panel. var onCloseRequest: (() -> Void)? @@ -140,6 +141,8 @@ final class AppState: ObservableObject { /// and disarm hover selection until the pointer moves again. func panelWillShow() { searchDebounceTask?.cancel() + slideout.cancelAutoOpen() + slideout.closePreview(animated: false) rawQuery = "" selectedID = nil pointerArmed = false diff --git a/Sources/ClapCore/ClipboardStore+Diagnostics.swift b/Sources/ClapCore/ClipboardStore+Diagnostics.swift index e50dd29..ae5ad2a 100644 --- a/Sources/ClapCore/ClipboardStore+Diagnostics.swift +++ b/Sources/ClapCore/ClipboardStore+Diagnostics.swift @@ -14,7 +14,7 @@ extension ClipboardStore { ConfigKey.retentionDays: "0", ConfigKey.launchAtLogin: "0", // Synthesize Cmd+V into the frontmost app after copying from the UI - // (Maccy-style). Requires Accessibility permission; falls back to + // Requires Accessibility permission; falls back to // copy-only when not granted. ConfigKey.pasteOnCopy: "1", // Shell history (zsh/bash) ingestion. diff --git a/Sources/ClapCore/Helpers.swift b/Sources/ClapCore/Helpers.swift index e88334d..20cf6c1 100644 --- a/Sources/ClapCore/Helpers.swift +++ b/Sources/ClapCore/Helpers.swift @@ -6,24 +6,36 @@ public enum TextSummaries { /// Collapses all whitespace runs and control characters to single spaces, /// trims, then truncates to `maxChars` appending an ellipsis when cut. public static func singleLine(_ s: String, maxChars: Int) -> 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 - for scalar in s.unicodeScalars { + 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 + 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) - guard trimmed.count > maxChars else { return trimmed } - let cutoff = trimmed.index(trimmed.startIndex, offsetBy: maxChars) - return String(trimmed[.. 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 @@ -32,9 +44,9 @@ public enum TextSummaries { 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)" } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + 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)" } } diff --git a/Tests/ClapCoreTests/TextAnalysisTests.swift b/Tests/ClapCoreTests/TextAnalysisTests.swift index b390685..acc4a8c 100644 --- a/Tests/ClapCoreTests/TextAnalysisTests.swift +++ b/Tests/ClapCoreTests/TextAnalysisTests.swift @@ -140,6 +140,9 @@ struct TextAnalysisTests { #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 From d26b05225819ca66595bec56b237e1fda68e5d9d Mon Sep 17 00:00:00 2001 From: spongycode Date: Sat, 22 Aug 2026 12:09:28 +0530 Subject: [PATCH 6/7] ci fixes --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bca632e..cf10c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest_stable + xcode-version: latest - name: Verify toolchain run: swift --version 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: | From 002e788aed6506479c1bd0431a660ef2ad05ad9b Mon Sep 17 00:00:00 2001 From: spongycode Date: Sat, 22 Aug 2026 12:13:15 +0530 Subject: [PATCH 7/7] lint fixes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf10c34..fd3feea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: - name: Lint run: | brew install swiftlint - swiftlint --reporter github-actions + swiftlint --reporter github-actions-logging