WIP: add code snippet cards
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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
|
## Copy And Paste
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum ClipboardItemKind: Int {
|
|||||||
case pdf
|
case pdf
|
||||||
case audio
|
case audio
|
||||||
case color
|
case color
|
||||||
|
case code
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -22,6 +23,7 @@ enum ClipboardItemKind: Int {
|
|||||||
case .pdf: return "PDF"
|
case .pdf: return "PDF"
|
||||||
case .audio: return "audio"
|
case .audio: return "audio"
|
||||||
case .color: return "color"
|
case .color: return "color"
|
||||||
|
case .code: return "code"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ extension ClipboardItemKind {
|
|||||||
switch self {
|
switch self {
|
||||||
case .url, .file, .image, .pdf, .audio:
|
case .url, .file, .image, .pdf, .audio:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .color:
|
case .text, .richText, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ extension ClipboardItemKind {
|
|||||||
switch self {
|
switch self {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .url, .color:
|
case .text, .richText, .unknown, .url, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ extension ClipboardItemKind {
|
|||||||
switch self {
|
switch self {
|
||||||
case .url, .image, .pdf, .audio, .richText:
|
case .url, .image, .pdf, .audio, .richText:
|
||||||
return true
|
return true
|
||||||
case .text, .file, .unknown, .color:
|
case .text, .file, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,8 +67,9 @@ enum ClipboardSortMode: Int {
|
|||||||
case files
|
case files
|
||||||
case audio
|
case audio
|
||||||
case colors
|
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 {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -79,6 +82,7 @@ enum ClipboardSortMode: Int {
|
|||||||
case .files: return "Files"
|
case .files: return "Files"
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .colors: return "Colors"
|
case .colors: return "Colors"
|
||||||
|
case .code: return "Code"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +155,7 @@ struct ClipboardItem {
|
|||||||
case .pdf: return "pdf document"
|
case .pdf: return "pdf document"
|
||||||
case .audio: return "audio sound"
|
case .audio: return "audio sound"
|
||||||
case .color: return "color swatch hex"
|
case .color: return "color swatch hex"
|
||||||
|
case .code: return "code snippet source programming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
106
sources/clipbored/models/CodeSnippetPayload.swift
Normal file
106
sources/clipbored/models/CodeSnippetPayload.swift
Normal file
@@ -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("<html") || lower.contains("</") && 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
return filePreviewThumbnail(for: item.payload)
|
return filePreviewThumbnail(for: item.payload)
|
||||||
|
|
||||||
case .text, .unknown, .audio, .richText, .color:
|
case .text, .unknown, .audio, .richText, .color, .code:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
let urls = FilePayload.urls(from: item.payload)
|
let urls = FilePayload.urls(from: item.payload)
|
||||||
return urls.first { fileManager.fileExists(atPath: $0.path) }
|
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
|
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
|
||||||
guard !text.clipboardTrimmed.isEmpty else { return nil }
|
guard !text.clipboardTrimmed.isEmpty else { return nil }
|
||||||
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
|
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
|
||||||
|
|||||||
@@ -254,17 +254,23 @@ final class ClipboardMonitorService {
|
|||||||
return htmlPayload
|
return htmlPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
if isIgnored(.text), let string = pasteboard.string(forType: .string) {
|
if let string = pasteboard.string(forType: .string) {
|
||||||
let trimmed = string.clipboardTrimmed
|
let trimmed = string.clipboardTrimmed
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
reportReadFailureStatus(ignoredKindMessage(.text))
|
if CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.code) {
|
||||||
return nil
|
reportReadFailureStatus(ignoredKindMessage(.code))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.text) {
|
||||||
|
reportReadFailureStatus(ignoredKindMessage(.text))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let string = pasteboard.string(forType: .string),
|
if let string = pasteboard.string(forType: .string),
|
||||||
let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) {
|
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.")
|
reportReadFailureStatus("Clipboard contains no readable text.")
|
||||||
return nil
|
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(
|
return ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: .text,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ final class ClipboardStore {
|
|||||||
func updateText(_ id: UUID, text: String) -> Bool {
|
func updateText(_ id: UUID, text: String) -> Bool {
|
||||||
guard !text.isEmpty,
|
guard !text.isEmpty,
|
||||||
let index = items.firstIndex(where: { $0.id == id }),
|
let index = items.firstIndex(where: { $0.id == id }),
|
||||||
items[index].kind == .text else {
|
items[index].kind == .text || items[index].kind == .code else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ final class PasteActionService {
|
|||||||
}
|
}
|
||||||
return [pasteboardItem]
|
return [pasteboardItem]
|
||||||
|
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
guard !item.payload.isEmpty else { return [] }
|
guard !item.payload.isEmpty else { return [] }
|
||||||
return [stringPasteboardItem(item.payload)]
|
return [stringPasteboardItem(item.payload)]
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@ final class PasteActionService {
|
|||||||
guard !item.payload.isEmpty else { return false }
|
guard !item.payload.isEmpty else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = writeURL(item.payload, title: item.displayText, to: board)
|
didWrite = writeURL(item.payload, title: item.displayText, to: board)
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
guard !item.payload.isEmpty else { return false }
|
guard !item.payload.isEmpty else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = board.setString(item.payload, forType: .string)
|
didWrite = board.setString(item.payload, forType: .string)
|
||||||
@@ -236,7 +236,7 @@ final class PasteActionService {
|
|||||||
|
|
||||||
func plainText(for item: ClipboardItem) -> String? {
|
func plainText(for item: ClipboardItem) -> String? {
|
||||||
switch item.kind {
|
switch item.kind {
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
||||||
case .url, .file:
|
case .url, .file:
|
||||||
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
22: .files,
|
22: .files,
|
||||||
26: .pinned,
|
26: .pinned,
|
||||||
28: .audio,
|
28: .audio,
|
||||||
25: .colors
|
25: .colors,
|
||||||
|
29: .code
|
||||||
]
|
]
|
||||||
|
|
||||||
private let viewModel: ClipboardPanelViewModel
|
private let viewModel: ClipboardPanelViewModel
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ private enum ClipboardCollectionVisuals {
|
|||||||
case .audio: return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
|
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 .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 .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 .audio: return "Audio"
|
||||||
case .files: return "Files"
|
case .files: return "Files"
|
||||||
case .pinned: return "Pinned"
|
case .pinned: return "Pinned"
|
||||||
|
case .code: return "Code"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +646,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
case .audio: return "music.note"
|
case .audio: return "music.note"
|
||||||
case .files: return "doc.fill"
|
case .files: return "doc.fill"
|
||||||
case .pinned: return "pin.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.")
|
return ("No links yet", "Links are detected from copied URLs.")
|
||||||
case .text:
|
case .text:
|
||||||
return ("No text clips yet", "Copied text and rich text appear here.")
|
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:
|
case .files:
|
||||||
return ("No files yet", "Copied files and PDFs appear here.")
|
return ("No files yet", "Copied files and PDFs appear here.")
|
||||||
case .audio:
|
case .audio:
|
||||||
@@ -3015,27 +3020,27 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .url, .file, .image, .pdf, .audio:
|
case .url, .file, .image, .pdf, .audio:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .color:
|
case .text, .richText, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canPreview: Bool {
|
private var canPreview: Bool {
|
||||||
switch itemKind {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canEditText: Bool {
|
private var canEditText: Bool {
|
||||||
itemKind == .text
|
itemKind == .text || itemKind == .code
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canPlainText: Bool {
|
private var canPlainText: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .url, .image, .richText, .file, .pdf, .audio, .color:
|
case .url, .image, .richText, .file, .pdf, .audio, .color:
|
||||||
return true
|
return true
|
||||||
case .text, .unknown:
|
case .text, .unknown, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3044,7 +3049,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .url, .unknown, .color:
|
case .text, .richText, .url, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3621,6 +3626,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return audioPreviewView(for: item)
|
return audioPreviewView(for: item)
|
||||||
case .color:
|
case .color:
|
||||||
return colorPreviewView(for: item)
|
return colorPreviewView(for: item)
|
||||||
|
case .code:
|
||||||
|
return codePreviewView(for: item)
|
||||||
case .text, .richText, .image, .unknown:
|
case .text, .richText, .image, .unknown:
|
||||||
return textPreviewView(for: item)
|
return textPreviewView(for: item)
|
||||||
}
|
}
|
||||||
@@ -4111,6 +4118,82 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return container
|
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 {
|
private func mediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
|
||||||
let container = NSView()
|
let container = NSView()
|
||||||
container.translatesAutoresizingMaskIntoConstraints = false
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -4196,6 +4279,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return "audio-preview"
|
return "audio-preview"
|
||||||
case .color:
|
case .color:
|
||||||
return "color-preview"
|
return "color-preview"
|
||||||
|
case .code:
|
||||||
|
return "code-preview"
|
||||||
case .richText:
|
case .richText:
|
||||||
return "rich-text-preview"
|
return "rich-text-preview"
|
||||||
case .text:
|
case .text:
|
||||||
@@ -4375,6 +4460,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return imageTitle(for: item)
|
return imageTitle(for: item)
|
||||||
case .color:
|
case .color:
|
||||||
return ColorPayload.displayHex(from: item.payload)
|
return ColorPayload.displayHex(from: item.payload)
|
||||||
|
case .code:
|
||||||
|
return CodeSnippetPayload.title(from: item.payload)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -4405,6 +4492,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return "Sound clip"
|
return "Sound clip"
|
||||||
case .color:
|
case .color:
|
||||||
return ColorPayload.componentSummary(from: item.payload)
|
return ColorPayload.componentSummary(from: item.payload)
|
||||||
|
case .code:
|
||||||
|
return CodeSnippetPayload.previewText(from: item.payload)
|
||||||
case .richText:
|
case .richText:
|
||||||
let text = normalized(item.displayText)
|
let text = normalized(item.displayText)
|
||||||
return text.isEmpty ? "No preview available" : text
|
return text.isEmpty ? "No preview available" : text
|
||||||
@@ -4438,6 +4527,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return "Audio"
|
return "Audio"
|
||||||
case .color:
|
case .color:
|
||||||
return "Color"
|
return "Color"
|
||||||
|
case .code:
|
||||||
|
return CodeSnippetPayload.languageLabel(from: item.payload)
|
||||||
case .image:
|
case .image:
|
||||||
if item.ocrText?.clipboardTrimmed.isEmpty == false {
|
if item.ocrText?.clipboardTrimmed.isEmpty == false {
|
||||||
return "OCR text"
|
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)
|
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
|
||||||
case .color:
|
case .color:
|
||||||
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
|
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:
|
case .unknown:
|
||||||
return .systemGray
|
return .systemGray
|
||||||
}
|
}
|
||||||
@@ -4769,6 +4862,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .pdf: return "doc.text.fill"
|
case .pdf: return "doc.text.fill"
|
||||||
case .audio: return "music.note"
|
case .audio: return "music.note"
|
||||||
case .color: return "paintpalette"
|
case .color: return "paintpalette"
|
||||||
|
case .code: return "chevron.left.forwardslash.chevron.right"
|
||||||
case .unknown: return "questionmark"
|
case .unknown: return "questionmark"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4841,6 +4935,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .pdf: return "PDF"
|
case .pdf: return "PDF"
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .color: return "Color"
|
case .color: return "Color"
|
||||||
|
case .code: return "Code"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -358,14 +358,14 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func editableTextForSelected() -> String? {
|
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
|
return item.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
func editableTextForItem(at index: Int) -> String? {
|
func editableTextForItem(at index: Int) -> String? {
|
||||||
guard index >= 0 && index < visibleItems.count else { return nil }
|
guard index >= 0 && index < visibleItems.count else { return nil }
|
||||||
let item = visibleItems[index]
|
let item = visibleItems[index]
|
||||||
guard item.kind == .text else { return nil }
|
guard item.kind == .text || item.kind == .code else { return nil }
|
||||||
return item.payload
|
return item.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateSelectedText(to text: String) {
|
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
|
let trimmed = text.clipboardTrimmed
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
statusMessage = "Text clip cannot be empty"
|
statusMessage = "Text clip cannot be empty"
|
||||||
@@ -401,7 +401,7 @@ final class ClipboardPanelViewModel {
|
|||||||
|
|
||||||
selectedItemID = item.id
|
selectedItemID = item.id
|
||||||
if store.updateText(item.id, text: text) {
|
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:
|
case .text:
|
||||||
return collectionFiltered
|
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)
|
.sorted(by: sortByUsage)
|
||||||
.map(\.1)
|
.map(\.1)
|
||||||
|
|
||||||
@@ -777,6 +777,12 @@ final class ClipboardPanelViewModel {
|
|||||||
.sorted(by: sortByUsage)
|
.sorted(by: sortByUsage)
|
||||||
.map(\.1)
|
.map(\.1)
|
||||||
|
|
||||||
|
case .code:
|
||||||
|
return collectionFiltered
|
||||||
|
.filter { $0.1.kind == .code }
|
||||||
|
.sorted(by: sortByUsage)
|
||||||
|
.map(\.1)
|
||||||
|
|
||||||
case .pinned:
|
case .pinned:
|
||||||
return collectionFiltered
|
return collectionFiltered
|
||||||
.filter { $0.1.isPinned }
|
.filter { $0.1.isPinned }
|
||||||
@@ -977,6 +983,8 @@ final class ClipboardPanelViewModel {
|
|||||||
return [.richText]
|
return [.richText]
|
||||||
case "note", "notes", "writing":
|
case "note", "notes", "writing":
|
||||||
return [.text, .richText]
|
return [.text, .richText]
|
||||||
|
case "code", "snippet", "snippets", "source", "programming", "script", "scripts", "json", "css", "sql":
|
||||||
|
return [.code]
|
||||||
case "link", "links", "url", "urls", "web":
|
case "link", "links", "url", "urls", "web":
|
||||||
return [.url]
|
return [.url]
|
||||||
case "image", "images", "photo", "photos", "picture", "pictures":
|
case "image", "images", "photo", "photos", "picture", "pictures":
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
|
|
||||||
let allowedRows = [
|
let allowedRows = [
|
||||||
kindCheckbox("Text", .text),
|
kindCheckbox("Text", .text),
|
||||||
|
kindCheckbox("Code", .code),
|
||||||
kindCheckbox("Links", .url),
|
kindCheckbox("Links", .url),
|
||||||
kindCheckbox("Images", .image),
|
kindCheckbox("Images", .image),
|
||||||
kindCheckbox("Colors", .color),
|
kindCheckbox("Colors", .color),
|
||||||
|
|||||||
@@ -108,6 +108,37 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
|
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() {
|
func testPollNowIgnoresClipBoredPasteboardWrites() {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
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.")
|
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 {
|
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
|
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
|
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
|
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
|
||||||
|
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {
|
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {
|
||||||
|
|||||||
@@ -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() {
|
func testSearchMatchesIndependentTokensCaseInsensitively() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let store = makeStore(settings: settings)
|
let store = makeStore(settings: settings)
|
||||||
|
|||||||
@@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
fixture.view.debugCollectionTitles,
|
fixture.view.debugCollectionTitles,
|
||||||
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned"]
|
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned", "Code"]
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
fixture.view.debugCollectionLeadingSymbols,
|
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")
|
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 color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store)
|
||||||
let audio = makeItem(kind: .audio, text: "audio payload", 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 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)
|
fixture.store.upsert($0)
|
||||||
drainMainQueue()
|
drainMainQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(fixture.viewModel.visibleItems.count, 7)
|
XCTAssertEqual(fixture.viewModel.visibleItems.count, 8)
|
||||||
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [7, 7, 2, 1, 1, 1, 1, 1, 1])
|
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1])
|
||||||
XCTAssertEqual(fixture.view.debugCollectionCounts, [7, 7, 2, 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))
|
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"])
|
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) {
|
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
return (fixture.window, fixture.view)
|
return (fixture.window, fixture.view)
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
|
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() {
|
func testPasteboardWritersExposeTextForDragOut() {
|
||||||
let service = PasteActionService()
|
let service = PasteActionService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
|
|||||||
Reference in New Issue
Block a user