WIP: add stack text batch actions
Some checks are pending
CI / Test And Build (push) Waiting to run

This commit is contained in:
Akshay Kolli
2026-07-01 15:58:30 -07:00
parent e1557f42b2
commit c7316105c7
8 changed files with 184 additions and 20 deletions

View File

@@ -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 - 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 - 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 - 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 - 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 - 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 - Local-only storage, with optional sensitive-content exclusion for common secrets

View File

@@ -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. 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. 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. 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 ## Copy And Paste

View File

@@ -32,7 +32,7 @@ final class ClipboardCacheService {
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true) try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
hardenDirectory(imageDirectory) hardenDirectory(imageDirectory)
hardenDirectory(attachmentDirectory) hardenDirectory(attachmentDirectory)
clearTemporaryPreviews() clearTemporaryPreviews(wait: true)
} }
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? { func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {

View File

@@ -77,27 +77,19 @@ final class PasteActionService {
} }
func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult { 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.") return .failed("Could not write plain text to clipboard.")
} }
guard let targetApp, return completePlainTextPaste(targetApp: targetApp)
!targetApp.isTerminated else { }
return .copiedPlainText
func pastePlainText(_ value: String, targetApp: NSRunningApplication?) -> PasteActionResult {
guard writePlainTextToPasteboard(value) else {
return .failed("Could not write plain text to clipboard.")
} }
guard accessibilityPermissionProvider() else { return completePlainTextPaste(targetApp: targetApp)
return .copiedPlainTextNeedsPermission
}
guard targetActivator(targetApp) else {
return .copiedPlainText
}
keyboardPasteScheduler { [weak self] in
self?.pasteViaKeyboard()
}
return .pastedPlainText
} }
@discardableResult @discardableResult
@@ -110,6 +102,11 @@ final class PasteActionService {
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.") 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] { func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] {
switch item.kind { switch item.kind {
case .image: case .image:
@@ -269,6 +266,12 @@ final class PasteActionService {
@discardableResult @discardableResult
func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool { func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool {
guard let text = plainText(for: item) else { return false } 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 let board = NSPasteboard.general
board.clearContents() board.clearContents()
let didWrite = board.setString(text, forType: .string) let didWrite = board.setString(text, forType: .string)
@@ -278,6 +281,26 @@ final class PasteActionService {
return didWrite 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 { private func stringPasteboardItem(_ value: String) -> NSPasteboardItem {
let pasteboardItem = NSPasteboardItem() let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(value, forType: .string) pasteboardItem.setString(value, forType: .string)

View File

@@ -585,6 +585,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
stackChip.onCopyStackNext = { [weak self] in stackChip.onCopyStackNext = { [weak self] in
self?.viewModel.copyNextStackItem() self?.viewModel.copyNextStackItem()
} }
stackChip.onPasteStackText = { [weak self] in
self?.viewModel.pasteStackAsText()
}
stackChip.onCopyStackText = { [weak self] in
self?.viewModel.copyStackAsText()
}
stackChip.onClearStack = { [weak self] in stackChip.onClearStack = { [weak self] in
self?.viewModel.clearStack() self?.viewModel.clearStack()
} }
@@ -806,6 +812,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
card.onCopyStackNext = { [weak self] in card.onCopyStackNext = { [weak self] in
self?.viewModel.copyNextStackItem() self?.viewModel.copyNextStackItem()
} }
card.onPasteStackText = { [weak self] in
self?.viewModel.pasteStackAsText()
}
card.onCopyStackText = { [weak self] in
self?.viewModel.copyStackAsText()
}
card.onClearStack = { [weak self] in card.onClearStack = { [weak self] in
self?.viewModel.clearStack() self?.viewModel.clearStack()
} }
@@ -2141,6 +2153,8 @@ private final class CollectionChipView: NSView {
var onAddVisibleToStack: (() -> Void)? var onAddVisibleToStack: (() -> Void)?
var onPasteStackNext: (() -> Void)? var onPasteStackNext: (() -> Void)?
var onCopyStackNext: (() -> Void)? var onCopyStackNext: (() -> Void)?
var onPasteStackText: (() -> Void)?
var onCopyStackText: (() -> Void)?
var onClearStack: (() -> Void)? var onClearStack: (() -> Void)?
var onEdit: (() -> Void)? var onEdit: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
@@ -2348,6 +2362,8 @@ private final class CollectionChipView: NSView {
onAddVisibleToStack != nil onAddVisibleToStack != nil
|| onPasteStackNext != nil || onPasteStackNext != nil
|| onCopyStackNext != nil || onCopyStackNext != nil
|| onPasteStackText != nil
|| onCopyStackText != nil
|| onClearStack != nil || onClearStack != nil
|| onEdit != nil || onEdit != nil
|| onDelete != nil || onDelete != nil
@@ -2371,6 +2387,16 @@ private final class CollectionChipView: NSView {
item.target = self item.target = self
menu.addItem(item) 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 { if onClearStack != nil {
let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "") let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "")
item.target = self item.target = self
@@ -2407,6 +2433,14 @@ private final class CollectionChipView: NSView {
onCopyStackNext?() onCopyStackNext?()
} }
@objc private func pasteStackTextFromMenu() {
onPasteStackText?()
}
@objc private func copyStackTextFromMenu() {
onCopyStackText?()
}
@objc private func clearStackFromMenu() { @objc private func clearStackFromMenu() {
onClearStack?() onClearStack?()
} }
@@ -2590,6 +2624,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
var onAddVisibleToStack: () -> Void = {} var onAddVisibleToStack: () -> Void = {}
var onPasteStackNext: () -> Void = {} var onPasteStackNext: () -> Void = {}
var onCopyStackNext: () -> Void = {} var onCopyStackNext: () -> Void = {}
var onPasteStackText: () -> Void = {}
var onCopyStackText: () -> Void = {}
var onClearStack: () -> Void = {} var onClearStack: () -> Void = {}
var onShowInClipboard: (Int) -> Void = { _ in } var onShowInClipboard: (Int) -> Void = { _ in }
var onRename: (Int) -> Void = { _ in } var onRename: (Int) -> Void = { _ in }
@@ -2972,6 +3008,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
if stackCount > 0 { if stackCount > 0 {
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu) addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), 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) addMenuItem("Clear Stack", action: #selector(clearStackFromMenu), to: menu)
} }
if canEditText { if canEditText {
@@ -3395,6 +3433,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onCopyStackNext() onCopyStackNext()
} }
@objc private func pasteStackTextFromMenu() {
onPasteStackText()
}
@objc private func copyStackTextFromMenu() {
onCopyStackText()
}
@objc private func clearStackFromMenu() { @objc private func clearStackFromMenu() {
onClearStack() onClearStack()
} }

View File

@@ -373,6 +373,29 @@ final class ClipboardPanelViewModel {
handleStackActionResult(result, item: item) 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] { func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] } guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index]) return pasteService.pasteboardWriters(for: visibleItems[index])
@@ -659,6 +682,20 @@ final class ClipboardPanelViewModel {
return items.first { $0.id == id } 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) { private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
if case .failed(let message) = result { if case .failed(let message) = result {
statusMessage = message statusMessage = message
@@ -681,6 +718,34 @@ final class ClipboardPanelViewModel {
settings.setPasteStatus(message: statusMessage) 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) { private func consumeStackItem(_ id: UUID) {
guard let index = stackItemIDs.firstIndex(of: id) else { return } guard let index = stackItemIDs.firstIndex(of: id) else { return }
stackItemIDs.remove(at: index) stackItemIDs.remove(at: index)

View File

@@ -874,6 +874,35 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.stackCount, 0) 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() { func testStackToggleAndClearUpdateCount() {
let settings = makeSettings() let settings = makeSettings()
let cacheService = makeCacheService() let cacheService = makeCacheService()

View File

@@ -974,7 +974,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles, 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.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"])
XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"]) XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"])
@@ -1073,7 +1073,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugStackChipCount, 1) XCTAssertEqual(fixture.view.debugStackChipCount, 1)
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugStackChipMenuTitles, 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() fixture.view.debugAddVisibleClipsToStackFromStackChip()