From ca3bdfbc70fa74fd76ddbc91c5a93c3d92624284 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Wed, 1 Jul 2026 15:12:49 -0700 Subject: [PATCH] WIP: add code snippet cards --- docs/SMOKE_TEST.md | 1 + sources/clipbored/models/ClipboardItem.swift | 13 ++- .../clipbored/models/CodeSnippetPayload.swift | 106 ++++++++++++++++++ .../services/ClipboardCacheService.swift | 4 +- .../services/ClipboardMonitorService.swift | 32 +++++- .../clipbored/services/ClipboardStore.swift | 2 +- .../services/PasteActionService.swift | 6 +- .../views/ClipboardPanelController.swift | 3 +- .../clipbored/views/ClipboardPanelView.swift | 105 ++++++++++++++++- .../views/ClipboardPanelViewModel.swift | 18 ++- .../views/SettingsWindowController.swift | 1 + .../ClipboardMonitorServiceTests.swift | 49 ++++++++ .../ClipboardPanelControllerTests.swift | 1 + .../ClipboardPanelViewModelTests.swift | 45 ++++++++ .../ClipboardPanelViewTests.swift | 41 ++++++- .../PasteActionServiceTests.swift | 21 ++++ 16 files changed, 417 insertions(+), 31 deletions(-) create mode 100644 sources/clipbored/models/CodeSnippetPayload.swift diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 2635122..5a82faa 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -67,6 +67,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout. 35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view. 36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text. +37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text. ## Copy And Paste diff --git a/sources/clipbored/models/ClipboardItem.swift b/sources/clipbored/models/ClipboardItem.swift index 0980259..fc94cdc 100644 --- a/sources/clipbored/models/ClipboardItem.swift +++ b/sources/clipbored/models/ClipboardItem.swift @@ -10,6 +10,7 @@ enum ClipboardItemKind: Int { case pdf case audio case color + case code var displayName: String { switch self { @@ -22,6 +23,7 @@ enum ClipboardItemKind: Int { case .pdf: return "PDF" case .audio: return "audio" case .color: return "color" + case .code: return "code" } } } @@ -31,7 +33,7 @@ extension ClipboardItemKind { switch self { case .url, .file, .image, .pdf, .audio: return true - case .text, .richText, .unknown, .color: + case .text, .richText, .unknown, .color, .code: return false } } @@ -40,7 +42,7 @@ extension ClipboardItemKind { switch self { case .file, .image, .pdf, .audio: return true - case .text, .richText, .unknown, .url, .color: + case .text, .richText, .unknown, .url, .color, .code: return false } } @@ -49,7 +51,7 @@ extension ClipboardItemKind { switch self { case .url, .image, .pdf, .audio, .richText: return true - case .text, .file, .unknown, .color: + case .text, .file, .unknown, .color, .code: return false } } @@ -65,8 +67,9 @@ enum ClipboardSortMode: Int { case files case audio case colors + case code - static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned] + static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned, .code] var title: String { switch self { @@ -79,6 +82,7 @@ enum ClipboardSortMode: Int { case .files: return "Files" case .audio: return "Audio" case .colors: return "Colors" + case .code: return "Code" } } } @@ -151,6 +155,7 @@ struct ClipboardItem { case .pdf: return "pdf document" case .audio: return "audio sound" case .color: return "color swatch hex" + case .code: return "code snippet source programming" } } diff --git a/sources/clipbored/models/CodeSnippetPayload.swift b/sources/clipbored/models/CodeSnippetPayload.swift new file mode 100644 index 0000000..3f48751 --- /dev/null +++ b/sources/clipbored/models/CodeSnippetPayload.swift @@ -0,0 +1,106 @@ +import Foundation + +enum CodeSnippetPayload { + static func isLikelyCode(_ value: String) -> Bool { + let text = value.clipboardTrimmed + guard text.count >= 12 else { return false } + if text.hasPrefix("```") { return true } + if languageLabel(from: text) != "Code" { return true } + + let lines = text.components(separatedBy: .newlines) + let nonEmptyLines = lines.map(\.clipboardTrimmed).filter { !$0.isEmpty } + guard !nonEmptyLines.isEmpty else { return false } + + var score = 0 + if nonEmptyLines.count >= 2 && lines.contains(where: { $0.hasPrefix(" ") || $0.hasPrefix("\t") }) { + score += 2 + } + if text.contains("{") && text.contains("}") { score += 2 } + if text.contains(";") { score += 1 } + if text.contains("=>") || text.contains("->") || text.contains("==") || text.contains("!=") || text.contains("&&") || text.contains("||") { + score += 1 + } + if nonEmptyLines.filter({ $0.hasSuffix("{") || $0.hasSuffix("}") || $0.hasSuffix(";") }).count >= 2 { + score += 2 + } + if containsCodeKeyword(text) { score += 2 } + if containsAssignment(text) { score += 1 } + + return score >= 4 + } + + static func languageLabel(from value: String) -> String { + let text = value.clipboardTrimmed + let lower = text.lowercased() + if isJSON(text) { return "JSON" } + if lower.contains("") { + return "HTML" + } + if lower.contains("#include") { return "C/C++" } + if lower.contains("func ") || lower.contains("let ") || lower.contains("var ") && lower.contains("->") { + return "Swift" + } + if lower.contains("function ") || lower.contains("const ") || lower.contains("let ") && lower.contains("=>") { + return "JavaScript" + } + if lower.contains("def ") || lower.contains("import ") && lower.contains(":") { + return "Python" + } + if lower.range(of: #"\b(select|insert|update|delete|create)\b[\s\S]+\b(from|into|table|set)\b"#, options: .regularExpression) != nil { + return "SQL" + } + if lower.range(of: #"^\s*(git|npm|yarn|pnpm|curl|ssh|docker|kubectl|brew|swift|python|node)\b"#, options: .regularExpression) != nil { + return "Shell" + } + if lower.contains("{") && lower.contains(":") && lower.contains(";") { + return "CSS" + } + return "Code" + } + + static func title(from value: String) -> String { + let language = languageLabel(from: value) + guard language != "Code" else { return "Code Snippet" } + return "\(language) Snippet" + } + + static func previewText(from value: String, maxLines: Int = 4) -> String { + let lines = value + .components(separatedBy: .newlines) + .map { $0.clipboardTrimmed } + .filter { !$0.isEmpty && $0 != "```" } + let preview = lines.prefix(maxLines).joined(separator: " ") + return preview.isEmpty ? "Code snippet" : String(preview.prefix(180)) + } + + static func previewLines(from value: String, maxLines: Int = 5) -> [String] { + let lines = value + .components(separatedBy: .newlines) + .map { line in + line.replacingOccurrences(of: "\t", with: " ") + } + .filter { !$0.clipboardTrimmed.isEmpty && $0.clipboardTrimmed != "```" } + return Array(lines.prefix(maxLines)) + } + + private static func containsCodeKeyword(_ value: String) -> Bool { + value.range( + of: #"\b(import|func|function|class|struct|enum|interface|return|guard|if|else|for|while|switch|case|try|catch|throw|async|await|public|private|static|const|let|var|def)\b"#, + options: [.regularExpression, .caseInsensitive] + ) != nil + } + + private static func containsAssignment(_ value: String) -> Bool { + value.range( + of: #"\b[A-Za-z_][A-Za-z0-9_]*\s*(=|:=)\s*[^=\n]"#, + options: .regularExpression + ) != nil + } + + private static func isJSON(_ value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + let trimmed = value.clipboardTrimmed + guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return false } + return (try? JSONSerialization.jsonObject(with: data)) != nil + } +} diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index b9a3732..f48ed5a 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -105,7 +105,7 @@ final class ClipboardCacheService { case .file: return filePreviewThumbnail(for: item.payload) - case .text, .unknown, .audio, .richText, .color: + case .text, .unknown, .audio, .richText, .color, .code: return nil } } @@ -187,7 +187,7 @@ final class ClipboardCacheService { case .file: let urls = FilePayload.urls(from: item.payload) return urls.first { fileManager.fileExists(atPath: $0.path) } - case .text, .unknown: + case .text, .code, .unknown: let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload guard !text.clipboardTrimmed.isEmpty else { return nil } return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt") diff --git a/sources/clipbored/services/ClipboardMonitorService.swift b/sources/clipbored/services/ClipboardMonitorService.swift index 1e3ae21..0a89f5b 100644 --- a/sources/clipbored/services/ClipboardMonitorService.swift +++ b/sources/clipbored/services/ClipboardMonitorService.swift @@ -254,17 +254,23 @@ final class ClipboardMonitorService { return htmlPayload } - if isIgnored(.text), let string = pasteboard.string(forType: .string) { + if let string = pasteboard.string(forType: .string) { let trimmed = string.clipboardTrimmed if !trimmed.isEmpty { - reportReadFailureStatus(ignoredKindMessage(.text)) - return nil + if CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.code) { + reportReadFailureStatus(ignoredKindMessage(.code)) + return nil + } + if !CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.text) { + reportReadFailureStatus(ignoredKindMessage(.text)) + return nil + } } } if let string = pasteboard.string(forType: .string), let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) { - if item.kind == .text, item.payload.isEmpty { + if (item.kind == .text || item.kind == .code), item.payload.isEmpty { reportReadFailureStatus("Clipboard contains no readable text.") return nil } @@ -305,6 +311,24 @@ final class ClipboardMonitorService { ) } + if CodeSnippetPayload.isLikelyCode(trimmed) { + return ClipboardItem( + id: UUID(), + kind: .code, + displayText: CodeSnippetPayload.title(from: trimmed), + payload: trimmed, + payloadHash: store.hashString(trimmed), + createdAt: Date(), + lastUsedAt: Date(), + useCount: 1, + sourceApp: sourceApp, + imagePath: nil, + thumbnailPath: nil, + isPinned: false, + sourceAppBundleId: sourceBundleId + ) + } + return ClipboardItem( id: UUID(), kind: .text, diff --git a/sources/clipbored/services/ClipboardStore.swift b/sources/clipbored/services/ClipboardStore.swift index 58b5598..1d417af 100644 --- a/sources/clipbored/services/ClipboardStore.swift +++ b/sources/clipbored/services/ClipboardStore.swift @@ -111,7 +111,7 @@ final class ClipboardStore { func updateText(_ id: UUID, text: String) -> Bool { guard !text.isEmpty, let index = items.firstIndex(where: { $0.id == id }), - items[index].kind == .text else { + items[index].kind == .text || items[index].kind == .code else { return false } diff --git a/sources/clipbored/services/PasteActionService.swift b/sources/clipbored/services/PasteActionService.swift index 742763a..7acc07b 100644 --- a/sources/clipbored/services/PasteActionService.swift +++ b/sources/clipbored/services/PasteActionService.swift @@ -165,7 +165,7 @@ final class PasteActionService { } return [pasteboardItem] - case .text, .unknown: + case .text, .code, .unknown: guard !item.payload.isEmpty else { return [] } return [stringPasteboardItem(item.payload)] } @@ -222,7 +222,7 @@ final class PasteActionService { guard !item.payload.isEmpty else { return false } board.clearContents() didWrite = writeURL(item.payload, title: item.displayText, to: board) - case .text, .unknown: + case .text, .code, .unknown: guard !item.payload.isEmpty else { return false } board.clearContents() didWrite = board.setString(item.payload, forType: .string) @@ -236,7 +236,7 @@ final class PasteActionService { func plainText(for item: ClipboardItem) -> String? { switch item.kind { - case .text, .unknown: + case .text, .code, .unknown: return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText) case .url, .file: return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText) diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 311447c..71633c1 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -83,7 +83,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel 22: .files, 26: .pinned, 28: .audio, - 25: .colors + 25: .colors, + 29: .code ] private let viewModel: ClipboardPanelViewModel diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 0942792..e919095 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -63,6 +63,7 @@ private enum ClipboardCollectionVisuals { case .audio: return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1) case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1) case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1) + case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1) } } @@ -630,6 +631,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { case .audio: return "Audio" case .files: return "Files" case .pinned: return "Pinned" + case .code: return "Code" } } @@ -644,6 +646,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { case .audio: return "music.note" case .files: return "doc.fill" case .pinned: return "pin.fill" + case .code: return "chevron.left.forwardslash.chevron.right" } } @@ -1139,6 +1142,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return ("No links yet", "Links are detected from copied URLs.") case .text: return ("No text clips yet", "Copied text and rich text appear here.") + case .code: + return ("No code snippets yet", "Copied code snippets appear here.") case .files: return ("No files yet", "Copied files and PDFs appear here.") case .audio: @@ -3015,27 +3020,27 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { switch itemKind { case .url, .file, .image, .pdf, .audio: return true - case .text, .richText, .unknown, .color: + case .text, .richText, .unknown, .color, .code: return false } } private var canPreview: Bool { switch itemKind { - case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color: + case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code: return true } } private var canEditText: Bool { - itemKind == .text + itemKind == .text || itemKind == .code } private var canPlainText: Bool { switch itemKind { case .url, .image, .richText, .file, .pdf, .audio, .color: return true - case .text, .unknown: + case .text, .unknown, .code: return false } } @@ -3044,7 +3049,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { switch itemKind { case .file, .image, .pdf, .audio: return true - case .text, .richText, .url, .unknown, .color: + case .text, .richText, .url, .unknown, .color, .code: return false } } @@ -3621,6 +3626,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return audioPreviewView(for: item) case .color: return colorPreviewView(for: item) + case .code: + return codePreviewView(for: item) case .text, .richText, .image, .unknown: return textPreviewView(for: item) } @@ -4111,6 +4118,82 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return container } + private func codePreviewView(for item: ClipboardItem) -> NSView { + let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor + container.translatesAutoresizingMaskIntoConstraints = false + + let editor = NSView() + editor.wantsLayer = true + editor.layer?.cornerRadius = 10 + editor.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.88).cgColor + editor.layer?.borderWidth = 0.8 + editor.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.22).cgColor + editor.translatesAutoresizingMaskIntoConstraints = false + + let language = capsuleLabel(CodeSnippetPayload.languageLabel(from: item.payload), color: accentColor(for: item.kind)) + language.translatesAutoresizingMaskIntoConstraints = false + + let lines = CodeSnippetPayload.previewLines(from: item.payload) + let lineRows = lines.isEmpty ? ["Code snippet"] : lines + let rowViews = lineRows.enumerated().map { offset, line in + codeLineRow(number: offset + 1, text: line) + } + let rows = NSStackView(views: rowViews) + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = 3 + rows.translatesAutoresizingMaskIntoConstraints = false + + editor.addSubview(rows) + container.addSubview(editor) + container.addSubview(language) + for row in rowViews { + row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true + } + + NSLayoutConstraint.activate([ + editor.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset), + editor.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset), + editor.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 13 : 16), + editor.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -13 : -16), + rows.leadingAnchor.constraint(equalTo: editor.leadingAnchor, constant: 12), + rows.trailingAnchor.constraint(equalTo: editor.trailingAnchor, constant: -12), + rows.topAnchor.constraint(equalTo: editor.topAnchor, constant: 13), + rows.bottomAnchor.constraint(lessThanOrEqualTo: editor.bottomAnchor, constant: -12), + language.trailingAnchor.constraint(equalTo: editor.trailingAnchor, constant: -10), + language.bottomAnchor.constraint(equalTo: editor.bottomAnchor, constant: -9) + ]) + return container + } + + private func codeLineRow(number: Int, text: String) -> NSView { + let numberLabel = NSTextField(labelWithString: "\(number)") + numberLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + numberLabel.textColor = .tertiaryLabelColor + numberLabel.alignment = .right + numberLabel.maximumNumberOfLines = 1 + numberLabel.translatesAutoresizingMaskIntoConstraints = false + numberLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true + + let codeLabel = NSTextField(labelWithString: text) + codeLabel.font = .monospacedSystemFont(ofSize: layout.isCompact ? 11 : 12, weight: .regular) + codeLabel.textColor = .labelColor + codeLabel.maximumNumberOfLines = 1 + codeLabel.lineBreakMode = .byTruncatingTail + codeLabel.toolTip = text + codeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let row = NSStackView(views: [numberLabel, codeLabel]) + row.orientation = .horizontal + row.alignment = .firstBaseline + row.spacing = 8 + row.translatesAutoresizingMaskIntoConstraints = false + codeLabel.widthAnchor.constraint(equalTo: row.widthAnchor, constant: -28).isActive = true + return row + } + private func mediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false @@ -4196,6 +4279,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "audio-preview" case .color: return "color-preview" + case .code: + return "code-preview" case .richText: return "rich-text-preview" case .text: @@ -4375,6 +4460,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return imageTitle(for: item) case .color: return ColorPayload.displayHex(from: item.payload) + case .code: + return CodeSnippetPayload.title(from: item.payload) default: break } @@ -4405,6 +4492,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "Sound clip" case .color: return ColorPayload.componentSummary(from: item.payload) + case .code: + return CodeSnippetPayload.previewText(from: item.payload) case .richText: let text = normalized(item.displayText) return text.isEmpty ? "No preview available" : text @@ -4438,6 +4527,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return "Audio" case .color: return "Color" + case .code: + return CodeSnippetPayload.languageLabel(from: item.payload) case .image: if item.ocrText?.clipboardTrimmed.isEmpty == false { return "OCR text" @@ -4754,6 +4845,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1) case .color: return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1) + case .code: + return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1) case .unknown: return .systemGray } @@ -4769,6 +4862,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .pdf: return "doc.text.fill" case .audio: return "music.note" case .color: return "paintpalette" + case .code: return "chevron.left.forwardslash.chevron.right" case .unknown: return "questionmark" } } @@ -4841,6 +4935,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { case .pdf: return "PDF" case .audio: return "Audio" case .color: return "Color" + case .code: return "Code" } } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 360d44f..959f82b 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -358,14 +358,14 @@ final class ClipboardPanelViewModel { } func editableTextForSelected() -> String? { - guard let item = selectedItem, item.kind == .text else { return nil } + guard let item = selectedItem, item.kind == .text || item.kind == .code else { return nil } return item.payload } func editableTextForItem(at index: Int) -> String? { guard index >= 0 && index < visibleItems.count else { return nil } let item = visibleItems[index] - guard item.kind == .text else { return nil } + guard item.kind == .text || item.kind == .code else { return nil } return item.payload } @@ -388,7 +388,7 @@ final class ClipboardPanelViewModel { } func updateSelectedText(to text: String) { - guard let item = selectedItem, item.kind == .text else { return } + guard let item = selectedItem, item.kind == .text || item.kind == .code else { return } let trimmed = text.clipboardTrimmed guard !trimmed.isEmpty else { statusMessage = "Text clip cannot be empty" @@ -401,7 +401,7 @@ final class ClipboardPanelViewModel { selectedItemID = item.id if store.updateText(item.id, text: text) { - statusMessage = "Updated text clip" + statusMessage = item.kind == .code ? "Updated code clip" : "Updated text clip" } } @@ -755,7 +755,7 @@ final class ClipboardPanelViewModel { case .text: return collectionFiltered - .filter { $0.1.kind == .text || $0.1.kind == .richText } + .filter { $0.1.kind == .text || $0.1.kind == .richText || $0.1.kind == .code } .sorted(by: sortByUsage) .map(\.1) @@ -777,6 +777,12 @@ final class ClipboardPanelViewModel { .sorted(by: sortByUsage) .map(\.1) + case .code: + return collectionFiltered + .filter { $0.1.kind == .code } + .sorted(by: sortByUsage) + .map(\.1) + case .pinned: return collectionFiltered .filter { $0.1.isPinned } @@ -977,6 +983,8 @@ final class ClipboardPanelViewModel { return [.richText] case "note", "notes", "writing": return [.text, .richText] + case "code", "snippet", "snippets", "source", "programming", "script", "scripts", "json", "css", "sql": + return [.code] case "link", "links", "url", "urls", "web": return [.url] case "image", "images", "photo", "photos", "picture", "pictures": diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index 0b72da5..b304738 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -167,6 +167,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD let allowedRows = [ kindCheckbox("Text", .text), + kindCheckbox("Code", .code), kindCheckbox("Links", .url), kindCheckbox("Images", .image), kindCheckbox("Colors", .color), diff --git a/tests/clipboredtests/ClipboardMonitorServiceTests.swift b/tests/clipboredtests/ClipboardMonitorServiceTests.swift index 95a7044..c66c812 100644 --- a/tests/clipboredtests/ClipboardMonitorServiceTests.swift +++ b/tests/clipboredtests/ClipboardMonitorServiceTests.swift @@ -108,6 +108,37 @@ final class ClipboardMonitorServiceTests: XCTestCase { XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1) } + func testPollNowCapturesCodeSnippetAsCode() { + let settings = SettingsModel(defaults: makeTestDefaults()) + settings.pruneDuplicates = false + let (store, cacheService) = makeStoreAndCache(settings: settings) + let monitor = ClipboardMonitorService( + store: store, + cacheService: cacheService, + settings: settings + ) + let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}" + + let captured = expectation(description: "code snippet captured") + store.observeItems { items in + if items.contains(where: { $0.kind == .code && $0.payload == snippet }) { + captured.fulfill() + } + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + XCTAssertTrue(pasteboard.setString(snippet, forType: .string)) + + monitor.pollNowAndWait() + wait(for: [captured], timeout: 1.0) + + let item = store.items.first + XCTAssertEqual(item?.kind, .code) + XCTAssertEqual(item?.displayText, "Swift Snippet") + XCTAssertEqual(item?.payload, snippet) + } + func testPollNowIgnoresClipBoredPasteboardWrites() { let settings = SettingsModel(defaults: makeTestDefaults()) let (store, cacheService) = makeStoreAndCache(settings: settings) @@ -639,6 +670,24 @@ final class ClipboardMonitorServiceTests: XCTestCase { XCTAssertEqual(settings.captureStatusMessage, "Skipped: Color items are ignored in capture settings.") } + func testIgnoredCodeKindDoesNotCaptureSnippet() throws { + let settings = SettingsModel(defaults: makeTestDefaults()) + settings.ignoredItemKindsRaw = [ClipboardItemKind.code.rawValue] + let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings) + let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings) + let snippet = "const title = \"ClipBored\";\nreturn title.toUpperCase();" + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + XCTAssertTrue(pasteboard.setString(snippet, forType: .string)) + + monitor.pollNowAndWait() + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertTrue(store.items.isEmpty) + XCTAssertEqual(settings.captureStatusMessage, "Skipped: Code items are ignored in capture settings.") + } + func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws { let settings = SettingsModel(defaults: makeTestDefaults()) settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue] diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index e3d7640..f35a983 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -157,6 +157,7 @@ final class ClipboardPanelControllerTests: XCTestCase { XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned) XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio) XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors) + XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code) } func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() { diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 2c98d26..7698b90 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -84,6 +84,51 @@ final class ClipboardPanelViewModelTests: XCTestCase { ) } + func testComputeVisibleItemsFiltersCodeSnippetsAndKeepsThemInTextView() { + let settings = makeSettings() + let store = makeStore(settings: settings) + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService()) + let code = ClipboardItem( + id: UUID(), + kind: .code, + displayText: "Swift Snippet", + payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", + payloadHash: hash("swift-snippet"), + createdAt: Date(timeIntervalSince1970: 200), + lastUsedAt: Date(timeIntervalSince1970: 200), + useCount: 0, + sourceApp: "Xcode", + imagePath: nil, + thumbnailPath: nil + ) + let text = ClipboardItem( + id: UUID(), + kind: .text, + displayText: "Meeting note", + payload: "Meeting note", + payloadHash: hash("Meeting note"), + createdAt: Date(timeIntervalSince1970: 100), + lastUsedAt: Date(timeIntervalSince1970: 100), + useCount: 0, + sourceApp: "Notes", + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual( + viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .code).map(\.kind), + [.code] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .text).map(\.kind), + [.code, .text] + ) + XCTAssertEqual( + viewModel.computeVisibleItems(from: [code, text], query: "type:snippet greet", sortMode: .mostRecent).map(\.kind), + [.code] + ) + } + func testSearchMatchesIndependentTokensCaseInsensitively() { let settings = makeSettings() let store = makeStore(settings: settings) diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index e4b5767..d50d863 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual( fixture.view.debugCollectionTitles, - ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned"] + ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned", "Code"] ) XCTAssertEqual( fixture.view.debugCollectionLeadingSymbols, - ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "doc.fill", "pin.fill"] + ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"] ) XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") @@ -681,15 +681,21 @@ final class ClipboardPanelViewTests: XCTestCase { let color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store) let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store) let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store) + let code = makeItem( + kind: .code, + displayText: "Swift Snippet", + payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", + store: fixture.store + ) - [pinned, rich, link, image, color, audio, file].forEach { + [pinned, rich, link, image, color, audio, file, code].forEach { fixture.store.upsert($0) drainMainQueue() } - XCTAssertEqual(fixture.viewModel.visibleItems.count, 7) - XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [7, 7, 2, 1, 1, 1, 1, 1, 1]) - XCTAssertEqual(fixture.view.debugCollectionCounts, [7, 7, 2, 1, 1, 1, 1, 1, 1]) + XCTAssertEqual(fixture.viewModel.visibleItems.count, 8) + XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1]) + XCTAssertEqual(fixture.view.debugCollectionCounts, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1]) XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count)) } @@ -1334,6 +1340,29 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["color-preview"]) } + func testCodeCardsUseMonospaceSnippetPreview() { + let fixture = makePanelFixture() + let item = makeItem( + kind: .code, + displayText: "Swift Snippet", + payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}", + store: fixture.store + ) + + fixture.store.upsert(item) + fixture.viewModel.sortMode = .code + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Code: Swift Snippet"]) + XCTAssertEqual( + fixture.view.debugCardPreviewSummaries, + ["Swift Snippet|func greet(name: String) -> String { return \"Hi \\(name)\" }|Swift"] + ) + XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["code-preview"]) + XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"]) + } + private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) { let fixture = makePanelFixture() return (fixture.window, fixture.view) diff --git a/tests/clipboredtests/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index 69f5877..60c5567 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -32,6 +32,27 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello") } + func testCopyWritesCodeSnippetAsPlainString() { + let service = PasteActionService() + let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}" + let item = ClipboardItem( + id: UUID(), + kind: .code, + displayText: "Swift Snippet", + payload: snippet, + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: "Xcode", + imagePath: nil, + thumbnailPath: nil + ) + + XCTAssertEqual(service.copy(item), .copied) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), snippet) + } + func testPasteboardWritersExposeTextForDragOut() { let service = PasteActionService() let item = ClipboardItem(