Skip to content
Merged
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
130 changes: 129 additions & 1 deletion Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import SwiftUI
import Carbon.HIToolbox

@MainActor
final class AppController: NSObject, NSApplicationDelegate {
final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate {
static let shared = AppController()

let store = ClipboardStore.shared
Expand All @@ -13,6 +13,8 @@ final class AppController: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var pauseMenuItem: NSMenuItem?
private var settingsWindow: NSWindow?
private var previewWindow: NSWindow?
private var previewedItemID: UUID?
private var keyMonitor: Any?

private(set) var previousApp: NSRunningApplication?
Expand All @@ -28,6 +30,7 @@ final class AppController: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
installMainMenu()

NSWorkspace.shared.notificationCenter.addObserver(
self, selector: #selector(appActivated(_:)),
Expand Down Expand Up @@ -83,6 +86,12 @@ final class AppController: NSObject, NSApplicationDelegate {
store.saveNow()
}

func windowWillClose(_ notification: Notification) {
if notification.object as? NSWindow === previewWindow {
previewedItemID = nil
}
}

/// Reopening from Finder, Spotlight, or the Dock surfaces the app.
///
/// The escape hatch comes first: Pesty is an accessory app, so with the status
Expand Down Expand Up @@ -110,6 +119,49 @@ final class AppController: NSObject, NSApplicationDelegate {
return true
}

private func installMainMenu() {
let main = NSMenu()

let appItem = NSMenuItem()
let appMenu = NSMenu()
appMenu.addItem(withTitle: "About Pesty", action: #selector(menuAbout), keyEquivalent: "").target = self
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Settings…", action: #selector(menuSettings), keyEquivalent: ",").target = self
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Hide Pesty", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
appMenu.addItem(withTitle: "Quit Pesty", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
appItem.submenu = appMenu
main.addItem(appItem)

let editItem = NSMenuItem()
let editMenu = NSMenu(title: "Edit")
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
redo.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(.separator())
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editMenu.addItem(.separator())
let find = editMenu.addItem(withTitle: "Find…",
action: #selector(NSTextView.performTextFinderAction(_:)),
keyEquivalent: "f")
find.tag = NSTextFinder.Action.showFindInterface.rawValue
editItem.submenu = editMenu
main.addItem(editItem)

let windowItem = NSMenuItem()
let windowMenu = NSMenu(title: "Window")
windowMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
windowItem.submenu = windowMenu
main.addItem(windowItem)
NSApp.windowsMenu = windowMenu

NSApp.mainMenu = main
}

private func setupStatusItem() {
guard statusItem == nil else { return }
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
Expand Down Expand Up @@ -277,6 +329,82 @@ final class AppController: NSObject, NSApplicationDelegate {
hideBar()
}

var pasteMenuTitle: String {
guard let name = pasteTarget?.localizedName,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "Paste"
}
return "Paste to \(name)"
}

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, let updated = store.item(withID: item.id) else { return }
if previewedItemID == item.id { showPreview(for: updated) }
}

func showPreview(for item: ClipItem) {
suppressAutoHide = true
defer { suppressAutoHide = false }
NSApp.activate(ignoringOtherApps: true)

let host = NSHostingController(rootView: ClipPreviewView(item: item))
let title = "Preview — \(item.displayTitle)"
previewedItemID = item.id

if let window = previewWindow {
window.title = title
window.contentViewController = host
window.makeKeyAndOrderFront(nil)
return
}

let window = NSWindow(contentViewController: host)
window.title = title
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 540, height: 400))
window.minSize = NSSize(width: 400, height: 260)
window.isReleasedWhenClosed = false
window.delegate = self
window.center()
previewWindow = window
window.makeKeyAndOrderFront(nil)
}

func showSharePicker(for item: ClipItem) {
let items = shareItems(for: item)
guard !items.isEmpty,
let view = barController?.window?.contentView ?? NSApp.keyWindow?.contentView else { return }
suppressAutoHide = true
defer { suppressAutoHide = false }
let picker = NSSharingServicePicker(items: items)
let anchor = NSRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1)
picker.show(relativeTo: anchor, of: view, preferredEdge: .maxY)
}

private func shareItems(for item: ClipItem) -> [Any] {
switch item.type {
case .image:
return store.loadImage(for: item).map { [$0] } ?? []
case .file:
let urls = item.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL)
return urls.isEmpty ? (item.plainText.map { [$0 as NSString] } ?? []) : urls
case .color, .text, .richText, .link:
return item.plainText.map { [$0 as NSString] } ?? []
Comment on lines +403 to +404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share link clips as URLs

For every link clip, this branch passes an NSString to NSSharingServicePicker instead of an NSURL. Sharing-service availability and behavior depend on the supplied object types, so URL-specific destinations are omitted and services that remain receive plain text rather than a link; the existing drag provider already preserves the intended semantics by registering an NSURL for .link items.

Useful? React with 👍 / 👎.

}
}

func deleteEffectiveSelection() {
let selection = store.effectiveSelectionIDs
let targets = store.visibleItems.filter { selection.contains($0.id) }
Expand Down
86 changes: 86 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,92 @@ final class ClipboardStore {
scheduleSave()
}

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

@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 = richTextData != nil ? .richText : (isWebLink(text) ? .link : .text)
return updateContent(of: 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 normalized = color.hexString
return updateContent(of: item) { existing in
var updated = existing
updated.type = .color
updated.text = nil
updated.rtfData = nil
updated.colorHex = normalized
return updated
}
}

private func updateContent(of item: ClipItem, transform: (ClipItem) -> ClipItem) -> Bool {
var changed = false
let now = Date()

if let i = history.firstIndex(where: { $0.id == item.id }) {
var updated = transform(history[i])
if updated != history[i] {
updated.createdAt = now

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the edited clip newer than its duplicate

If the same-content duplicate has a createdAt later than Date()—for example after syncing from a Mac whose clock was ahead or correcting the local clock—this assignment leaves the edited record older before the duplicate is removed. When another device receives the edited-record save and duplicate-record deletion together, applyRemoteToHistory can discard the edit in favor of the newer duplicate and then apply that duplicate's deletion, losing both clips and reconciling the edit into a remote delete. Derive the new timestamp from the maximum matching duplicate timestamp rather than assuming the local current time is newer.

Useful? React with 👍 / 👎.

history.remove(at: i)
removeContentDuplicates(of: updated, in: &history)
history.insert(updated, at: 0)
changed = true
}
}

for b in pinboards.indices {
guard let i = pinboards[b].items.firstIndex(where: { $0.id == item.id }) else { continue }
var updated = transform(pinboards[b].items[i])
if updated != pinboards[b].items[i] {
updated.createdAt = now
pinboards[b].items[i] = updated
removeContentDuplicates(of: updated, in: &pinboards[b].items)
changed = true
}
}

guard changed else { return false }
retentionPrunedRecordNames.remove(item.id.uuidString)
if selectedItem == nil { selectFirst() }
reconcileMultiSelection()
scheduleSave()
return true
}

private func removeContentDuplicates(of item: ClipItem, in items: inout [ClipItem]) {
let key = contentKey(item)
let duplicates = items.filter { $0.id != item.id && contentKey($0) == key }
guard !duplicates.isEmpty else { return }
items.removeAll { $0.id != item.id && contentKey($0) == key }
for duplicate in duplicates { deleteImageFile(duplicate) }
}

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 setTitle(_ title: String, for item: ClipItem) {
if let i = history.firstIndex(where: { $0.id == item.id }) { history[i].customTitle = title }
for b in pinboards.indices {
Expand Down
Loading
Loading