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

@@ -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)? {

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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)