Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,30 @@ final class AppController: NSObject, NSApplicationDelegate {
copyToast.show()
}

func editItem(_ item: ClipItem, launchWritingTools: Bool = false) {
suppressAutoHide = true
defer { suppressAutoHide = false }

guard let edit = ClipEditor.run(for: item, launchWritingTools: launchWritingTools) else { return }
let changed: Bool
switch edit {
case let .text(text, richTextData):
changed = store.updateTextContent(text, richTextData: richTextData, for: item)
case let .color(hex):
changed = store.updateColorContent(hex, for: item)
}
guard changed else { return }

// An explicit Edit makes the revised item the current clipboard too.
// Suppress the monitor so this write updates the existing record rather
// than capturing a duplicate history item.
if let updatedItem = store.item(withID: item.id) {
let change = PasteService.copy(updatedItem)
monitor.suppressUntilChangeCount = change
}
refreshQuickLookPreview()
}

func commandCopy() {
if store.source == .pasteStack, let entry = pasteSequence.selectedEntry {
copyItem(entry.item)
Expand All @@ -258,6 +282,15 @@ final class AppController: NSObject, NSApplicationDelegate {
copySelected()
}

private func refreshQuickLookPreview() {
if store.source == .pasteStack {
QuickLookService.shared.refresh(items: pasteSequence.displayEntries.map(\.item),
selectedID: pasteSequence.selectedEntry?.item.id)
} else {
QuickLookService.shared.refresh(items: store.visibleItems, selectedID: store.selectedID)
}
}

func beginDragOut() {
// Let AppKit establish the dragging session before taking the source
// panel offscreen; the drag then continues naturally into another app.
Expand Down
93 changes: 93 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,99 @@ final class ClipboardStore {
scheduleSave()
}

/// Returns the latest canonical copy after an edit, including clips that
/// currently live only in a saved Paste Stack.
func item(withID id: UUID) -> ClipItem? {
if let item = history.first(where: { $0.id == id }) { return item }
if let item = pinboards.lazy.flatMap(\.items).first(where: { $0.id == id }) { return item }
return PasteSequence.shared.item(withID: id)
}

/// Updates the actual text payload while preserving a clip's identity,
/// source metadata, creation date, and any separately assigned title.
@discardableResult
func updateTextContent(_ text: String, richTextData: Data? = nil, for item: ClipItem) -> Bool {
guard [.text, .richText, .link].contains(item.type),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }

let type: ClipType
if richTextData != nil {
type = .richText
} else {
type = isWebLink(text) ? .link : .text
}

return updateContent(for: item) { existing in
var updated = existing
updated.type = type
updated.text = text
updated.rtfData = richTextData
updated.colorHex = nil
return updated
}
}

@discardableResult
func updateColorContent(_ hex: String, for item: ClipItem) -> Bool {
guard item.type == .color, let color = NSColor(hex: hex) else { return false }
let normalizedHex = color.hexString

return updateContent(for: item) { existing in
var updated = existing
updated.type = .color
updated.text = nil
updated.rtfData = nil
updated.colorHex = normalizedHex
return updated
}
}

@discardableResult
private func updateContent(for item: ClipItem,
transform: (ClipItem) -> ClipItem) -> Bool {
var changed = false

if let i = history.firstIndex(where: { $0.id == item.id }) {
let updated = transform(history[i])
if updated != history[i] {
history[i] = updated
changed = true
}
}
for boardIndex in pinboards.indices {
for itemIndex in pinboards[boardIndex].items.indices where pinboards[boardIndex].items[itemIndex].id == item.id {
let updated = transform(pinboards[boardIndex].items[itemIndex])
if updated != pinboards[boardIndex].items[itemIndex] {
pinboards[boardIndex].items[itemIndex] = updated
changed = true
}
}
}

if PasteSequence.shared.updateItem(item.id, transform: transform) {
changed = true
}
guard changed else { return false }

// An edited clip can stop matching the active search. Keep navigation
// and the inline preview attached to a card that is still visible.
if source != .pasteStack, selectedItem == nil {
selectFirst()
}
scheduleSave()
return true
}

private func isWebLink(_ text: String) -> Bool {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains(" "), !value.contains("\n"),
let url = URL(string: value),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
url.host != nil else { return false }
return true
}

func selectFirst() { selectedID = visibleItems.first?.id }

func prepareForBarPresentation() {
Expand Down
48 changes: 48 additions & 0 deletions Sources/Pesty/Store/PasteSequence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ struct PasteStackEntry: Identifiable, Codable {
self.isPasted = false
}

init(id: UUID, item: ClipItem, imagePreview: NSImage?, isPasted: Bool) {
self.id = id
self.item = item
self.imagePreview = imagePreview
self.isPasted = isPasted
}

private enum CodingKeys: String, CodingKey { case id, item, isPasted }

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -97,6 +104,14 @@ final class PasteSequence {
return entries.first(where: { $0.id == selectedEntryID })
}

func item(withID id: UUID) -> ClipItem? {
if let entry = entries.first(where: { $0.item.id == id }) { return entry.item }
return savedStacks.lazy
.flatMap(\.entries)
.first(where: { $0.item.id == id })?
.item
}

// Kept as an alias while the main bar transitions from the old queue mode.
var isBuilding: Bool { isCollecting }

Expand Down Expand Up @@ -289,6 +304,39 @@ final class PasteSequence {
ClipboardStore.shared.pasteStacksDidChange()
}

/// Keeps saved Paste Stack copies in sync when the canonical clipboard
/// item is edited. Entry IDs and paste progress are intentionally retained.
@discardableResult
func updateItem(_ id: UUID, transform: (ClipItem) -> ClipItem) -> Bool {
var changed = false

for stackIndex in savedStacks.indices {
for entryIndex in savedStacks[stackIndex].entries.indices {
let entry = savedStacks[stackIndex].entries[entryIndex]
guard entry.item.id == id else { continue }

let updatedItem = transform(entry.item)
guard updatedItem != entry.item else { continue }
savedStacks[stackIndex].entries[entryIndex] = PasteStackEntry(
id: entry.id,
item: updatedItem,
imagePreview: entry.imagePreview,
isPasted: entry.isPasted
)
savedStacks[stackIndex].updatedAt = .now
changed = true
}
}

guard changed else { return false }
if let activeStackID,
let active = savedStacks.first(where: { $0.id == activeStackID }) {
entries = active.entries
}
ClipboardStore.shared.pasteStacksDidChange()
return true
}

private func ensureActiveStack() {
if let activeStackID,
savedStacks.contains(where: { $0.id == activeStackID }) { return }
Expand Down
8 changes: 8 additions & 0 deletions Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,21 @@ struct ClipCardView: View {
}
Button("Copy") { AppController.shared.copyItem(item) }
Divider()
Button { AppController.shared.editItem(item) } label: {
Label("Edit", systemImage: "pencil")
}
Divider()
Button("Remove from Paste Stack", role: .destructive) {
AppController.shared.removePasteStackEntry(entry)
}
} else {
Button("Paste") { AppController.shared.pasteItem(item) }
Button("Copy") { AppController.shared.copyItem(item) }
Divider()
Button { AppController.shared.editItem(item) } label: {
Label("Edit", systemImage: "pencil")
}
Divider()
if !store.pinboards.isEmpty {
Menu("Save to Pinboard") {
ForEach(store.pinboards) { b in
Expand Down
153 changes: 153 additions & 0 deletions Sources/Pesty/UI/ClipEditor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import AppKit

/// Native modal editing for the clipboard content itself, distinct from a
/// custom card title. The editor intentionally returns data only; persistence
/// and pasteboard policy remain owned by AppController and ClipboardStore.
@MainActor
enum ClipEditor {
enum Edit {
case text(String, richTextData: Data?)
case color(String)
}

static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? {
switch item.type {
case .text, .richText, .link:
return editText(item, launchWritingTools: launchWritingTools)
case .color:
return editColor(item)
case .image, .file:
showUnsupportedEditor(for: item)
return nil
}
}

private static func editText(_ item: ClipItem, launchWritingTools: Bool) -> Edit? {
let isRichText = item.type == .richText
let alert = NSAlert()
alert.messageText = "Edit \(item.type.label)"
alert.informativeText = isRichText
? "Edit this saved clip. Its rich-text formatting is preserved when possible."
: "Edit this saved clip's contents."
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")

let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 430, height: 220))
textView.isRichText = isRichText
textView.importsGraphics = false
textView.allowsUndo = true
textView.isEditable = true
textView.isSelectable = true
textView.font = .systemFont(ofSize: 13)
textView.textContainer?.containerSize = NSSize(width: 430, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
if #available(macOS 15.0, *) {
textView.writingToolsBehavior = .complete
}

if isRichText,
let data = item.rtfData,
let value = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.rtf],
documentAttributes: nil
) {
textView.textStorage?.setAttributedString(value)
} else {
textView.string = item.text ?? ""
}

let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 430, height: 220))
scrollView.borderType = .bezelBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.documentView = textView
alert.accessoryView = scrollView
alert.window.initialFirstResponder = textView
if launchWritingTools {
DispatchQueue.main.async {
guard #available(macOS 15.2, *) else { return }
alert.window.makeFirstResponder(textView)
_ = NSApp.sendAction(#selector(NSResponder.showWritingTools(_:)), to: nil, from: textView)
}
}

guard alert.runModal() == .alertFirstButtonReturn else { return nil }
let text = textView.string
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
showEmptyTextWarning()
return nil
}

let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
let richTextData = isRichText ? textView.rtf(from: range) : nil
return .text(text, richTextData: richTextData)
}

private static func editColor(_ item: ClipItem) -> Edit? {
let alert = NSAlert()
alert.messageText = "Edit Color"
alert.informativeText = "Choose the color stored in this saved clip."
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")

let initialColor = item.colorHex.flatMap(NSColor.init(hex:)) ?? .black
let accessory = ColorEditorAccessoryView(color: initialColor)
alert.accessoryView = accessory

guard alert.runModal() == .alertFirstButtonReturn else { return nil }
return .color(accessory.selectedHex)
}

private static func showUnsupportedEditor(for item: ClipItem) {
let alert = NSAlert()
alert.messageText = "This clip can't be edited"
alert.informativeText = "Pesty can edit text, rich text, links, and colors. \(item.type.label) clips are kept as-is."
alert.addButton(withTitle: "OK")
alert.runModal()
}

private static func showEmptyTextWarning() {
let alert = NSAlert()
alert.messageText = "Clip content can't be empty"
alert.informativeText = "Enter some text before saving this clip."
alert.addButton(withTitle: "OK")
alert.runModal()
}
}

@MainActor
private final class ColorEditorAccessoryView: NSStackView {
private let colorWell: NSColorWell
private let valueLabel: NSTextField

init(color: NSColor) {
colorWell = NSColorWell()
valueLabel = NSTextField(labelWithString: color.hexString)
super.init(frame: NSRect(x: 0, y: 0, width: 260, height: 32))

orientation = .horizontal
alignment = .centerY
spacing = 10

let label = NSTextField(labelWithString: "Color:")
valueLabel.font = .monospacedSystemFont(ofSize: 12, weight: .medium)
valueLabel.textColor = .secondaryLabelColor
colorWell.color = color
colorWell.target = self
colorWell.action = #selector(colorDidChange)
colorWell.widthAnchor.constraint(equalToConstant: 42).isActive = true

addArrangedSubview(label)
addArrangedSubview(colorWell)
addArrangedSubview(valueLabel)
}

required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

var selectedHex: String { colorWell.color.hexString }

@objc private func colorDidChange() {
valueLabel.stringValue = selectedHex
}
}
Loading