This commit is contained in:
@@ -24,7 +24,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
||||
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
|
||||
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
|
||||
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
|
||||
- Copy and paste actions with Accessibility permission fallback
|
||||
- Copy and paste actions with Accessibility permission fallback, including Stack next-item actions and Stack-as-text batch copy/paste
|
||||
- Image thumbnail cache with byte and file-count pruning
|
||||
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
|
||||
- Local-only storage, with optional sensitive-content exclusion for common secrets
|
||||
|
||||
@@ -71,6 +71,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
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.
|
||||
38. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data.
|
||||
39. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order.
|
||||
40. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack.
|
||||
|
||||
## Copy And Paste
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ final class ClipboardCacheService {
|
||||
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
||||
hardenDirectory(imageDirectory)
|
||||
hardenDirectory(attachmentDirectory)
|
||||
clearTemporaryPreviews()
|
||||
clearTemporaryPreviews(wait: true)
|
||||
}
|
||||
|
||||
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
|
||||
|
||||
@@ -77,27 +77,19 @@ final class PasteActionService {
|
||||
}
|
||||
|
||||
func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard writePlainTextToPasteboard(item) else {
|
||||
guard let text = plainText(for: item), writePlainTextToPasteboard(text) else {
|
||||
return .failed("Could not write plain text to clipboard.")
|
||||
}
|
||||
|
||||
guard let targetApp,
|
||||
!targetApp.isTerminated else {
|
||||
return .copiedPlainText
|
||||
return completePlainTextPaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
func pastePlainText(_ value: String, targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard writePlainTextToPasteboard(value) else {
|
||||
return .failed("Could not write plain text to clipboard.")
|
||||
}
|
||||
|
||||
guard accessibilityPermissionProvider() else {
|
||||
return .copiedPlainTextNeedsPermission
|
||||
}
|
||||
|
||||
guard targetActivator(targetApp) else {
|
||||
return .copiedPlainText
|
||||
}
|
||||
|
||||
keyboardPasteScheduler { [weak self] in
|
||||
self?.pasteViaKeyboard()
|
||||
}
|
||||
return .pastedPlainText
|
||||
return completePlainTextPaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -110,6 +102,11 @@ final class PasteActionService {
|
||||
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copyPlainText(_ value: String) -> PasteActionResult {
|
||||
writePlainTextToPasteboard(value) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||
}
|
||||
|
||||
func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] {
|
||||
switch item.kind {
|
||||
case .image:
|
||||
@@ -269,6 +266,12 @@ final class PasteActionService {
|
||||
@discardableResult
|
||||
func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool {
|
||||
guard let text = plainText(for: item) else { return false }
|
||||
return writePlainTextToPasteboard(text)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func writePlainTextToPasteboard(_ text: String) -> Bool {
|
||||
guard !text.clipboardTrimmed.isEmpty else { return false }
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
let didWrite = board.setString(text, forType: .string)
|
||||
@@ -278,6 +281,26 @@ final class PasteActionService {
|
||||
return didWrite
|
||||
}
|
||||
|
||||
private func completePlainTextPaste(targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard let targetApp,
|
||||
!targetApp.isTerminated else {
|
||||
return .copiedPlainText
|
||||
}
|
||||
|
||||
guard accessibilityPermissionProvider() else {
|
||||
return .copiedPlainTextNeedsPermission
|
||||
}
|
||||
|
||||
guard targetActivator(targetApp) else {
|
||||
return .copiedPlainText
|
||||
}
|
||||
|
||||
keyboardPasteScheduler { [weak self] in
|
||||
self?.pasteViaKeyboard()
|
||||
}
|
||||
return .pastedPlainText
|
||||
}
|
||||
|
||||
private func stringPasteboardItem(_ value: String) -> NSPasteboardItem {
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
pasteboardItem.setString(value, forType: .string)
|
||||
|
||||
@@ -585,6 +585,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
stackChip.onCopyStackNext = { [weak self] in
|
||||
self?.viewModel.copyNextStackItem()
|
||||
}
|
||||
stackChip.onPasteStackText = { [weak self] in
|
||||
self?.viewModel.pasteStackAsText()
|
||||
}
|
||||
stackChip.onCopyStackText = { [weak self] in
|
||||
self?.viewModel.copyStackAsText()
|
||||
}
|
||||
stackChip.onClearStack = { [weak self] in
|
||||
self?.viewModel.clearStack()
|
||||
}
|
||||
@@ -806,6 +812,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
card.onCopyStackNext = { [weak self] in
|
||||
self?.viewModel.copyNextStackItem()
|
||||
}
|
||||
card.onPasteStackText = { [weak self] in
|
||||
self?.viewModel.pasteStackAsText()
|
||||
}
|
||||
card.onCopyStackText = { [weak self] in
|
||||
self?.viewModel.copyStackAsText()
|
||||
}
|
||||
card.onClearStack = { [weak self] in
|
||||
self?.viewModel.clearStack()
|
||||
}
|
||||
@@ -2141,6 +2153,8 @@ private final class CollectionChipView: NSView {
|
||||
var onAddVisibleToStack: (() -> Void)?
|
||||
var onPasteStackNext: (() -> Void)?
|
||||
var onCopyStackNext: (() -> Void)?
|
||||
var onPasteStackText: (() -> Void)?
|
||||
var onCopyStackText: (() -> Void)?
|
||||
var onClearStack: (() -> Void)?
|
||||
var onEdit: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
@@ -2348,6 +2362,8 @@ private final class CollectionChipView: NSView {
|
||||
onAddVisibleToStack != nil
|
||||
|| onPasteStackNext != nil
|
||||
|| onCopyStackNext != nil
|
||||
|| onPasteStackText != nil
|
||||
|| onCopyStackText != nil
|
||||
|| onClearStack != nil
|
||||
|| onEdit != nil
|
||||
|| onDelete != nil
|
||||
@@ -2371,6 +2387,16 @@ private final class CollectionChipView: NSView {
|
||||
item.target = self
|
||||
menu.addItem(item)
|
||||
}
|
||||
if onPasteStackText != nil {
|
||||
let item = NSMenuItem(title: "Paste Stack as Text", action: #selector(pasteStackTextFromMenu), keyEquivalent: "")
|
||||
item.target = self
|
||||
menu.addItem(item)
|
||||
}
|
||||
if onCopyStackText != nil {
|
||||
let item = NSMenuItem(title: "Copy Stack as Text", action: #selector(copyStackTextFromMenu), keyEquivalent: "")
|
||||
item.target = self
|
||||
menu.addItem(item)
|
||||
}
|
||||
if onClearStack != nil {
|
||||
let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "")
|
||||
item.target = self
|
||||
@@ -2407,6 +2433,14 @@ private final class CollectionChipView: NSView {
|
||||
onCopyStackNext?()
|
||||
}
|
||||
|
||||
@objc private func pasteStackTextFromMenu() {
|
||||
onPasteStackText?()
|
||||
}
|
||||
|
||||
@objc private func copyStackTextFromMenu() {
|
||||
onCopyStackText?()
|
||||
}
|
||||
|
||||
@objc private func clearStackFromMenu() {
|
||||
onClearStack?()
|
||||
}
|
||||
@@ -2590,6 +2624,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
var onAddVisibleToStack: () -> Void = {}
|
||||
var onPasteStackNext: () -> Void = {}
|
||||
var onCopyStackNext: () -> Void = {}
|
||||
var onPasteStackText: () -> Void = {}
|
||||
var onCopyStackText: () -> Void = {}
|
||||
var onClearStack: () -> Void = {}
|
||||
var onShowInClipboard: (Int) -> Void = { _ in }
|
||||
var onRename: (Int) -> Void = { _ in }
|
||||
@@ -2972,6 +3008,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
if stackCount > 0 {
|
||||
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
|
||||
addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu)
|
||||
addMenuItem("Paste Stack as Text", action: #selector(pasteStackTextFromMenu), to: menu)
|
||||
addMenuItem("Copy Stack as Text", action: #selector(copyStackTextFromMenu), to: menu)
|
||||
addMenuItem("Clear Stack", action: #selector(clearStackFromMenu), to: menu)
|
||||
}
|
||||
if canEditText {
|
||||
@@ -3395,6 +3433,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
onCopyStackNext()
|
||||
}
|
||||
|
||||
@objc private func pasteStackTextFromMenu() {
|
||||
onPasteStackText()
|
||||
}
|
||||
|
||||
@objc private func copyStackTextFromMenu() {
|
||||
onCopyStackText()
|
||||
}
|
||||
|
||||
@objc private func clearStackFromMenu() {
|
||||
onClearStack()
|
||||
}
|
||||
|
||||
@@ -373,6 +373,29 @@ final class ClipboardPanelViewModel {
|
||||
handleStackActionResult(result, item: item)
|
||||
}
|
||||
|
||||
func copyStackAsText() {
|
||||
guard let package = stackPlainTextPackage() else {
|
||||
statusMessage = "Stack has no text to copy"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.copyPlainText(package.text)
|
||||
handleStackPlainTextActionResult(result, items: package.items)
|
||||
}
|
||||
|
||||
func pasteStackAsText() {
|
||||
guard let package = stackPlainTextPackage() else {
|
||||
statusMessage = "Stack has no text to paste"
|
||||
return
|
||||
}
|
||||
|
||||
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
|
||||
if case .pastedPlainText = result {
|
||||
willPasteToTarget()
|
||||
}
|
||||
handleStackPlainTextActionResult(result, items: package.items)
|
||||
}
|
||||
|
||||
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
|
||||
guard index >= 0 && index < visibleItems.count else { return [] }
|
||||
return pasteService.pasteboardWriters(for: visibleItems[index])
|
||||
@@ -659,6 +682,20 @@ final class ClipboardPanelViewModel {
|
||||
return items.first { $0.id == id }
|
||||
}
|
||||
|
||||
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
|
||||
pruneStackItems()
|
||||
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
|
||||
guard let item = items.first(where: { $0.id == id }),
|
||||
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
|
||||
!text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return (item, text)
|
||||
}
|
||||
guard !pairs.isEmpty else { return nil }
|
||||
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
|
||||
}
|
||||
|
||||
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
@@ -681,6 +718,34 @@ final class ClipboardPanelViewModel {
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
|
||||
if case .failed(let message) = result {
|
||||
statusMessage = message
|
||||
return
|
||||
}
|
||||
|
||||
for item in items {
|
||||
store.markUsed(item.id)
|
||||
consumeStackItem(item.id)
|
||||
}
|
||||
if stackItemIDs.isEmpty {
|
||||
isStackFilterSelected = false
|
||||
}
|
||||
selectedItemID = items.first?.id
|
||||
let noun = items.count == 1 ? "clip" : "clips"
|
||||
switch result {
|
||||
case .pastedPlainText:
|
||||
statusMessage = "Pasted \(items.count) Stack \(noun) as Text"
|
||||
case .copiedPlainTextNeedsPermission:
|
||||
statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
|
||||
case .copiedPlainText:
|
||||
statusMessage = "Copied \(items.count) Stack \(noun) as Text"
|
||||
default:
|
||||
statusMessage = result.message
|
||||
}
|
||||
settings.setPasteStatus(message: statusMessage)
|
||||
}
|
||||
|
||||
private func consumeStackItem(_ id: UUID) {
|
||||
guard let index = stackItemIDs.firstIndex(of: id) else { return }
|
||||
stackItemIDs.remove(at: index)
|
||||
|
||||
@@ -874,6 +874,35 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.stackCount, 0)
|
||||
}
|
||||
|
||||
func testStackCopiesQueuedClipsAsPlainTextBatchAndConsumesThem() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
let first = makeTextItem("first reusable stack clip", createdAt: Date(timeIntervalSince1970: 100))
|
||||
let second = makeTextItem("second reusable stack clip", createdAt: Date(timeIntervalSince1970: 200))
|
||||
store.upsert(first)
|
||||
store.upsert(second)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
NSPasteboard.general.clearContents()
|
||||
|
||||
viewModel.selectItem(at: 1)
|
||||
viewModel.toggleSelectedStackMembership()
|
||||
viewModel.selectItem(at: 0)
|
||||
viewModel.toggleSelectedStackMembership()
|
||||
|
||||
viewModel.copyStackAsText()
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "first reusable stack clip\n\nsecond reusable stack clip")
|
||||
XCTAssertEqual(viewModel.statusMessage, "Copied 2 Stack clips as Text")
|
||||
XCTAssertEqual(viewModel.stackCount, 0)
|
||||
XCTAssertEqual(store.items.first(where: { $0.id == first.id })?.useCount, 1)
|
||||
XCTAssertEqual(store.items.first(where: { $0.id == second.id })?.useCount, 1)
|
||||
}
|
||||
|
||||
func testStackToggleAndClearUpdateCount() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
|
||||
@@ -974,7 +974,7 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugFirstCardMenuTitles,
|
||||
["Paste", "Copy", "Rename...", "Remove from Stack", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
|
||||
["Paste", "Copy", "Rename...", "Remove from Stack", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
|
||||
)
|
||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"])
|
||||
XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"])
|
||||
@@ -1073,7 +1073,7 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
XCTAssertEqual(fixture.view.debugStackChipCount, 1)
|
||||
XCTAssertEqual(
|
||||
fixture.view.debugStackChipMenuTitles,
|
||||
["Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack"]
|
||||
["Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack"]
|
||||
)
|
||||
|
||||
fixture.view.debugAddVisibleClipsToStackFromStackChip()
|
||||
|
||||
Reference in New Issue
Block a user