Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d9121f8
Add a standalone SwiftPM smoke test
alvst Aug 13, 2026
7419181
Add a "Play sound on copy" setting, distinct from paste
alvst Oct 8, 2026
bc5fad5
Say which copy the sound setting means
momenbasel Oct 8, 2026
88342b8
Show history's on-disk size in Settings
alvst Aug 16, 2026
abdc742
Keep the storage row current and count what it measures
momenbasel Oct 8, 2026
f8e948f
Show real dimensions and paths in the clip card footer
alvst Aug 19, 2026
3e1ff4a
Remember missing images and abbreviate paths with the real home
momenbasel Oct 8, 2026
3488572
Turn history retention into a discrete slider with named presets
alvst Aug 16, 2026
d84964d
Apply the retention slider when the drag ends
momenbasel Oct 8, 2026
61e8240
Add a selected-clip position preference
alvst Oct 8, 2026
4ed0909
Add clip color themes
alvst Oct 8, 2026
bd1e2cf
Keep the existing palette as the default clip color theme
momenbasel Oct 8, 2026
4b1d814
Show image previews on cards and real icons on file clips
momenbasel Oct 8, 2026
3b52791
Copy the selected clip with Command-C
alvst Aug 15, 2026
0f59f7e
Promote copied clips without borrowing a Pinboard identity
momenbasel Oct 8, 2026
479bacd
Add Clean Formatting and Markdown paste options
alvst Oct 8, 2026
c7cdf8a
Convert captured HTML as is and keep it only where RTF is missing
momenbasel Oct 8, 2026
d106242
Replace the search indicator with a real editable field
alvst Aug 15, 2026
b66d10a
Keep Return, quick paste and the arrows working while typing a search
momenbasel Oct 8, 2026
4cf9eb6
Add a live-resize handle for the Paste Bar
alvst Oct 8, 2026
c25905a
Keep the resize handle and drop the height ghost
momenbasel Oct 8, 2026
2763443
Undo a deletion for five minutes with Command-Z
momenbasel Oct 8, 2026
ecbbd47
Add a promote-on-paste option and a notice when direct paste is blocked
momenbasel Oct 8, 2026
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
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ let package = Package(
swiftSettings: [
.unsafeFlags(["-parse-as-library"])
]
),
.testTarget(
name: "PestyTests",
dependencies: ["Pesty"],
path: "Tests/PestyTests"
)
],
swiftLanguageModes: [.v5]
Expand Down
198 changes: 179 additions & 19 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {

let store = ClipboardStore.shared
let monitor = ClipboardMonitor()
private let copyToast = CopyToastController()

private var barController: BarWindowController?
private var statusItem: NSStatusItem?
Expand All @@ -16,6 +17,9 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var previewWindow: NSWindow?
private var previewedItemID: UUID?
private var keyMonitor: Any?
#if !MAS
private var notedBlockedDirectPaste = false
#endif

private(set) var previousApp: NSRunningApplication?
private(set) var lastActiveApp: NSRunningApplication?
Expand Down Expand Up @@ -83,6 +87,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
}

func applicationWillTerminate(_ notification: Notification) {
store.forgetDeletions()
store.saveNow()
}

Expand Down Expand Up @@ -236,6 +241,26 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
ClipboardStore.shared.setICloudSync(enabling)
}

func beginBarResize(at screenPoint: NSPoint) {
barController?.beginBarResize(at: screenPoint)
}

func updateBarResize(at screenPoint: NSPoint) {
barController?.updateBarResize(at: screenPoint)
}

func endBarResize(at screenPoint: NSPoint) {
barController?.endBarResize(at: screenPoint)
}

func cancelBarResize() {
barController?.cancelBarResize()
}

func adjustBarHeight(by delta: CGFloat) {
barController?.adjustBarHeight(by: delta)
}

static func restart() {
let path = Bundle.main.bundlePath
let task = Process()
Expand Down Expand Up @@ -279,6 +304,12 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
previousApp = front
lastActiveApp = front
}
// The panel keeps its first responder across orderOut, so a field
// left focused by a hide mid-search would otherwise own the next
// presentation's keys: Return would not paste and arrows would move
// a caret in an empty query.
barController?.resignSearch()
store.barInputMode = .cards
store.searchText = ""
store.source = .history
store.prepareForBarPresentation()
Expand All @@ -297,6 +328,38 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
barController?.hide()
}

func setBarSearchEditing(_ editing: Bool) {
let mode: BarInputMode = editing ? .search : .cards
guard store.barInputMode != mode else { return }
store.barInputMode = mode
}

/// Routes every edit from the native search field through here instead
/// of writing `store.searchText` directly, so the selection follows the
/// filtered results as you type instead of staying on whatever was
/// selected before the query changed.
func updateBarSearchText(_ text: String) {
guard store.searchText != text else { return }
store.searchText = text
store.selectFirst()
}

func clearBarSearch() {
let hadQuery = !store.searchText.isEmpty
barController?.resignSearch()
store.searchText = ""
store.barInputMode = .cards
if hadQuery { store.selectFirst(); searchClearedAt = Date() }
}

func cancelBarSearchOrHide() {
if !store.searchText.isEmpty {
clearBarSearch()
} else {
hideBar()
}
}

func pasteSelected() {
guard let item = store.selectedItem else { return }
pasteItem(item)
Expand All @@ -317,18 +380,49 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
return app.bundleIdentifier == bundleID
}

func pasteItem(_ item: ClipItem, asPlainText: Bool = false) {
func pasteItem(_ item: ClipItem, format: PasteFormat = .original) {
let target = pasteTarget
hideBar()
PasteService.paste(item, into: target, monitor: monitor, asPlainText: asPlainText)
let previousChange = NSPasteboard.general.changeCount
PasteService.paste(item, into: target, monitor: monitor, format: format)
if Settings.shared.promoteOnPaste, NSPasteboard.general.changeCount != previousChange {
store.promoteCopiedItem(item)
}
}

#if !MAS
/// A direct paste that quietly falls back to a copy reads as "paste is
/// broken". Say so once per launch, in the toast rather than a modal:
/// the clip is on the pasteboard and Command-V finishes the job.
func noteBlockedDirectPaste() {
guard !notedBlockedDirectPaste else { return }
notedBlockedDirectPaste = true
copyToast.show(message: "Copied. Grant Accessibility in System Settings to paste directly.",
symbol: "exclamationmark.triangle.fill",
linger: 4)
}
#endif

func copyItem(_ item: ClipItem) {
let previousChange = NSPasteboard.general.changeCount
let change = PasteService.copy(item)
monitor.suppressUntilChangeCount = change
// PasteService.copy leaves the pasteboard alone when an image file is
// missing; neither the promotion nor the toast should claim otherwise.
if change != previousChange {
store.promoteCopiedItem(item)
copyToast.show()
}
// Tink, not Pop: copy and paste stay audibly distinct.
if Settings.shared.playSoundOnCopy { NSSound(named: "Tink")?.play() }
hideBar()
}

func copySelected() {
guard let item = store.selectedItem else { return }
copyItem(item)
}

var pasteMenuTitle: String {
guard let name = pasteTarget?.localizedName,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
Expand Down Expand Up @@ -405,28 +499,34 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
}
}

func deleteEffectiveSelection() {
/// `permanently` skips Undo for this one deletion: the user held Option.
func deleteEffectiveSelection(permanently: Bool = false) {
let selection = store.effectiveSelectionIDs
let targets = store.visibleItems.filter { selection.contains($0.id) }
guard !targets.isEmpty else { return }
if targets.count == 1 {
store.delete(targets[0])
store.delete(targets[0], permanently: permanently)
return
}
suppressAutoHide = true
defer { suppressAutoHide = false }
let alert = NSAlert()
alert.messageText = "Delete \(targets.count) Clips?"
let noUndo = permanently || Settings.shared.deletePermanently
#if MAS
alert.informativeText = "There is no undo. When iCloud sync is on, these clips are also removed from your other devices."
alert.informativeText = noUndo
? "This cannot be undone. When iCloud sync is on, these clips are also removed from your other devices."
: "You can undo this with ⌘Z for the next 5 minutes. When iCloud sync is on, these clips are also removed from your other devices."
#else
alert.informativeText = "There is no undo."
alert.informativeText = noUndo
? "This cannot be undone."
: "You can undo this with ⌘Z for the next 5 minutes."
#endif
let confirm = alert.addButton(withTitle: "Delete \(targets.count) Clips")
confirm.hasDestructiveAction = true
alert.addButton(withTitle: "Cancel")
guard alert.runModal() == .alertFirstButtonReturn else { return }
store.delete(items: targets)
store.delete(items: targets, permanently: permanently)
}

func deleteSelection(containing item: ClipItem) {
Expand Down Expand Up @@ -492,7 +592,10 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
// Events belonging to a native context menu, editor, alert, or the
// Settings window must stay with their own responder chain. The bar
// monitor is only responsible for keys delivered to the panel itself.
guard event.window === barController?.window else { return event }
guard let barWindow = barController?.window,
event.window === barWindow else { return event }

if barWindow.firstResponder is BarResizeHandleResponder { return event }

if handleBarCommandShortcut(event) { return nil }

Expand All @@ -508,11 +611,20 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
if digit <= items.count {
let plain = includes(Settings.shared.plainTextModifier, in: flags)
&& Settings.shared.plainTextModifier != Settings.shared.quickPasteModifier
pasteItem(items[digit - 1], asPlainText: plain)
pasteItem(items[digit - 1], format: plain ? .plainText : .original)
}
return nil
}

// Past the quick-paste digits, the native search field owns the event
// while it is editing: selection, clipboard commands, deletion, spaces,
// keyboard layouts, and composed text all need real AppKit
// text-editing behavior. Return and the arrows come back through the
// field's delegate.
if barController?.searchOwnsFirstResponder == true {
return event
}

switch code {
case kVK_Escape:
if !store.multiSelectedIDs.isEmpty {
Expand All @@ -535,8 +647,17 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
case kVK_DownArrow:
store.moveSelection(by: 1); return nil
case kVK_Delete:
if cmd { deleteEffectiveSelection(); return nil }
if cmd { deleteEffectiveSelection(permanently: opt); return nil }
// Backspace edits the query before it can remove a filtered clip,
// even when focus has already moved back to the cards (e.g.
// after Return submitted the search). Refocusing the native
// field and returning the same event lets it handle the
// backspace itself, rather than manually mutating the string.
if !store.searchText.isEmpty {
store.barInputMode = .search
if barController?.focusSearchAtEnd() == true {
return event
}
store.searchText.removeLast(); store.selectFirst()
if store.searchText.isEmpty { searchClearedAt = Date() }
return nil
Expand All @@ -546,26 +667,38 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
// Backspace that just finished clearing a query must not start
// deleting clips at the key-repeat rate - and a short cooldown
// after the search empties separates "clear the query" from
// "delete a clip". There is no undo, and deletions replicate to
// "delete a clip". Command-Z brings a deleted clip back for five
// minutes unless Option was held, and deletions replicate to
// other devices when sync is on.
if !event.isARepeat,
Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown {
deleteEffectiveSelection()
deleteEffectiveSelection(permanently: opt)
}
return nil
case kVK_ForwardDelete:
deleteEffectiveSelection()
deleteEffectiveSelection(permanently: opt)
return nil
case kVK_ANSI_Z:
if cmd, !ctrl, !opt, !flags.contains(.shift), store.undoLastDelete() { return nil }
case kVK_ANSI_C:
if cmd, !ctrl, !opt, !flags.contains(.shift) { copySelected(); return nil }
default:
break
}

if !cmd && !ctrl && !opt,
let chars = event.characters, chars.count == 1,
let scalar = chars.unicodeScalars.first,
scalar.value >= 32, scalar.value != 127 {
store.searchText.append(chars)
store.selectFirst()
if isPrintableTextIntent(event) {
store.barInputMode = .search
if barController?.focusSearchAtEnd() == true {
// The local monitor runs before responder dispatch. Returning
// the same event now sends its very first character directly
// to the newly focused native field editor — no character is
// lost transferring focus mid-keystroke.
return event
}
if let chars = fallbackSearchCharacters(from: event) {
store.searchText.append(chars)
store.selectFirst()
}
return nil
}
return event
Expand All @@ -584,6 +717,33 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
store.selectFirst()
}

private func isPrintableTextIntent(_ event: NSEvent) -> Bool {
let flags = event.modifierFlags
guard !flags.contains(.command),
!flags.contains(.control),
let chars = event.charactersIgnoringModifiers,
!chars.isEmpty else { return false }
return chars.unicodeScalars.contains {
$0.value >= 0x20
&& $0.value != 0x7F
&& !(0xF700...0xF8FF).contains($0.value)
}
}

private func fallbackSearchCharacters(from event: NSEvent) -> String? {
let flags = event.modifierFlags
guard !flags.contains(.command),
!flags.contains(.control),
let chars = event.characters,
!chars.isEmpty,
chars.unicodeScalars.allSatisfy({
$0.value >= 0x20
&& $0.value != 0x7F
&& !(0xF700...0xF8FF).contains($0.value)
}) else { return nil }
return chars
}

private static func quickPasteDigit(for keyCode: Int) -> Int? {
switch keyCode {
case kVK_ANSI_1, kVK_ANSI_Keypad1: return 1
Expand Down
3 changes: 3 additions & 0 deletions Sources/Pesty/Models/ClipItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct ClipItem: Identifiable, Codable, Equatable {
var type: ClipType
var text: String?
var rtfData: Data?
var htmlData: Data?
var imageFileName: String?
var imageHash: String?
var fileURLs: [String]
Expand All @@ -20,6 +21,7 @@ struct ClipItem: Identifiable, Codable, Equatable {
type: ClipType,
text: String? = nil,
rtfData: Data? = nil,
htmlData: Data? = nil,
imageFileName: String? = nil,
imageHash: String? = nil,
fileURLs: [String] = [],
Expand All @@ -32,6 +34,7 @@ struct ClipItem: Identifiable, Codable, Equatable {
self.type = type
self.text = text
self.rtfData = rtfData
self.htmlData = htmlData
self.imageFileName = imageFileName
self.imageHash = imageHash
self.fileURLs = fileURLs
Expand Down
11 changes: 10 additions & 1 deletion Sources/Pesty/Monitor/ClipboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import CryptoKit
@MainActor
final class ClipboardMonitor {
private static let sourceType = NSPasteboard.PasteboardType("org.nspasteboard.source")
private static let htmlCaptureLimit = 64 * 1024
private let pasteboard = NSPasteboard.general
private var lastChangeCount: Int
private var timer: Timer?
Expand Down Expand Up @@ -99,6 +100,14 @@ final class ClipboardMonitor {
}

let rtf = pasteboard.data(forType: .rtf)
// Browsers often provide HTML with no RTF; keep it for the Markdown
// and Clean Formatting paste conversions without changing how the
// clip is classified. RTF already serves those conversions, so HTML
// is stored only when there is none, and only up to a bound that
// keeps store.json, rewritten on every capture, from growing by the
// size of every web page copied from.
var html = rtf == nil ? pasteboard.data(forType: .html) : nil
if let data = html, data.count > Self.htmlCaptureLimit { html = nil }
if let string = pasteboard.string(forType: .string), !string.isEmpty {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
let type: ClipType
Expand All @@ -109,7 +118,7 @@ final class ClipboardMonitor {
} else {
type = .text
}
var item = ClipItem(type: type, text: string, rtfData: rtf)
var item = ClipItem(type: type, text: string, rtfData: rtf, htmlData: html)
decorate(&item)
return item
}
Expand Down
Loading
Loading