From 79d5a54f0070005292a0b10a8e9f4153ebe9f22d Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:17:09 +0530 Subject: [PATCH 1/5] Snippets: add a paste-anywhere text snippet library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New menu bar helper (DMonteSnippets, com.havokentity.mactools.snippets): a searchable library of reusable text. Clicking a snippet — or pressing Return on the top search hit — copies it and pastes it into whatever app the user was working in before the panel appeared, which is the same problem Clipboard History already solved, so the paste target comes from ActivationTracker via the panel host's onWillShow hook and the ⌘V synthesis is shared rather than reimplemented: ClipboardPaste grows a pasteText(_:sourceBundleID:into:) alongside the existing entry paste, and both now funnel through one permission check. Missing Accessibility is reported honestly in the panel and in Settings — the text still reaches the clipboard, so the tool degrades to copy-only instead of failing silently. Snippets are created, edited, reordered and deleted in the popover and persist as JSON in Application Support with owner-only permissions, since a snippet library holds exactly the private boilerplate (addresses, account numbers, signatures) that should not be world readable. Writes follow ClipboardStore's post-fix shape: debounced off the main actor, serialized through a generation-checked writer so a slow large snapshot cannot land after a newer small one and resurrect a deleted snippet, and flushed at applicationWillTerminate so quitting right after Save does not lose the snippet. Placeholder expansion is deliberately a three-token substitution list ({date}, {time}, {datetime}) and not a templating language: anything richer becomes a syntax the user must learn and the tool must version. It is opt-out per snippet so text that legitimately contains braces can be pasted verbatim, and the editor's help string is generated from SnippetsKit.placeholders so the documentation cannot drift from what is actually substituted. Filtering, expansion, reordering and the JSON round-trip live in SnippetsKit; SnippetsKitTests covers them plus the survives-a-quit contract, driving a real store pointed at a temp directory. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 + Package.swift | 11 + Packaging/SnippetsInfo.plist | 32 + README.md | 5 +- Scripts/package_app.sh | 1 + Sources/DMonteCore/ClipboardPaste.swift | 30 + Sources/DMonteCore/SnippetsController.swift | 264 +++++++++ Sources/DMonteCore/SnippetsKit.swift | 183 ++++++ Sources/DMonteCore/SnippetsSizing.swift | 15 + Sources/DMonteCore/SnippetsStore.swift | 140 +++++ Sources/DMonteCore/SnippetsView.swift | 553 ++++++++++++++++++ Sources/DMonteCore/ToolboxCatalog.swift | 3 +- .../SnippetsAppDelegate.swift | 133 +++++ Sources/DMonteSnippetsApp/main.swift | 24 + Tests/DMonteCoreTests/SnippetsKitTests.swift | 285 +++++++++ 15 files changed, 1684 insertions(+), 3 deletions(-) create mode 100644 Packaging/SnippetsInfo.plist create mode 100644 Sources/DMonteCore/SnippetsController.swift create mode 100644 Sources/DMonteCore/SnippetsKit.swift create mode 100644 Sources/DMonteCore/SnippetsSizing.swift create mode 100644 Sources/DMonteCore/SnippetsStore.swift create mode 100644 Sources/DMonteCore/SnippetsView.swift create mode 100644 Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift create mode 100644 Sources/DMonteSnippetsApp/main.swift create mode 100644 Tests/DMonteCoreTests/SnippetsKitTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5cf2f..b4eacaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org) and the ## [Unreleased] ### Added +- **Snippets**, a new menu bar tool: a searchable library of reusable text. + Click a snippet (or press ⏎ on the top search hit) to copy it and paste it + straight into the app you were working in, the way Clipboard History does. + Snippets are created, edited, reordered and deleted in the popover and stored + locally with owner-only permissions. Each one can opt into date/time + placeholders — `{date}`, `{time}` and `{datetime}` are replaced as it pastes; + anything else in braces is left alone. Pasting needs Accessibility access; + without it the snippet is still copied and the popover says so. - Continuous integration: every push and pull request now builds the package and runs the full test suite on a pinned macOS runner, using the same toolchain the release workflow ships with. Previously only version tags ran diff --git a/Package.swift b/Package.swift index e40120a..8e2f708 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,10 @@ let package = Package( .executable( name: "DMonteWindowManager", targets: ["DMonteWindowManager"] + ), + .executable( + name: "DMonteSnippets", + targets: ["DMonteSnippets"] ) ], dependencies: [ @@ -253,6 +257,13 @@ let package = Package( ], path: "Sources/DMonteWindowManagerApp" ), + .executableTarget( + name: "DMonteSnippets", + dependencies: [ + "DMonteCore" + ], + path: "Sources/DMonteSnippetsApp" + ), .testTarget( name: "DMonteCoreTests", dependencies: ["DMonteCore"], diff --git a/Packaging/SnippetsInfo.plist b/Packaging/SnippetsInfo.plist new file mode 100644 index 0000000..ec8f2e3 --- /dev/null +++ b/Packaging/SnippetsInfo.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + DMonteSnippets + CFBundleIdentifier + com.havokentity.mactools.snippets + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + DMonte Snippets + CFBundleName + DMonte Snippets + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.13.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSMultipleInstancesProhibited + + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Yahushad Monte + + diff --git a/README.md b/README.md index 925c765..28914ca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/havokentity/MacTools/actions/workflows/ci.yml/badge.svg)](https://github.com/havokentity/MacTools/actions/workflows/ci.yml) -A native macOS menu bar tool box — 20 fast, focused utilities in one lightweight Swift app, inspired by the convenience of Parallels Toolbox. +A native macOS menu bar tool box — 21 fast, focused utilities in one lightweight Swift app, inspired by the convenience of Parallels Toolbox. Open the tool box from the menu bar, search for what you need, and launch it. Each tool is a self-contained helper with its own polished SwiftUI interface; tools that run continuously (like System Monitor and Clipboard History) add their own menu bar item. @@ -42,13 +42,14 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org); | **Keep Awake** | Prevent sleep, optionally for a set duration | | **Maintenance** | Handy Finder/system toggles and cache/index refreshes | | **Dev Tools** | JSON, Base64, URL, hashing, UUID, timestamp, and case utilities | +| **Snippets** | A searchable library of reusable text — click one to paste it into the app you came from | ### Permissions A few tools ask macOS for access the first time you use them, and degrade gracefully if you decline: - **Window Manager** — Accessibility (to move other apps' windows) -- **Clipboard History** — Accessibility (to paste into the active app) +- **Clipboard History** / **Snippets** — Accessibility (to paste into the active app) - **Grab Text** / **QR Studio** (screen scan) — Screen Recording - **Volume Mixer** — System Audio Recording (`NSAudioCaptureUsageDescription`, to tap each app's audio for per‑app volume and routing) - **Calendar** — Calendar access diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9b0f96b..5a2ec3f 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -36,6 +36,7 @@ HELPERS=( "DMonteGrabText|DMonte Grab Text.app|GrabTextInfo.plist" "DMonteFocusTimer|DMonte Focus Timer.app|FocusTimerInfo.plist" "DMonteWindowManager|DMonte Window Manager.app|WindowManagerInfo.plist" + "DMonteSnippets|DMonte Snippets.app|SnippetsInfo.plist" ) stamp_version() { diff --git a/Sources/DMonteCore/ClipboardPaste.swift b/Sources/DMonteCore/ClipboardPaste.swift index 0c730fa..4712cec 100644 --- a/Sources/DMonteCore/ClipboardPaste.swift +++ b/Sources/DMonteCore/ClipboardPaste.swift @@ -101,6 +101,36 @@ public enum ClipboardPaste { // it is — the caller would show the permission banner. guard copyToPasteboard(entry, store: store, asPlainText: asPlainText) else { return true } + return pasteCopiedContent(into: target) + } + + /// Puts plain `text` on the pasteboard and pastes it into `target`, stamped as coming from + /// `sourceBundleID` (the nspasteboard.org convention every clipboard manager reads). Shared + /// by the tools that paste text they generated rather than text they captured — Snippets — + /// so the permission handling and the ⌘V synthesis have exactly one implementation. + /// + /// Same contract as `paste(_:store:asPlainText:into:)`: false means only that Accessibility + /// is missing; the text is on the clipboard either way, ready for a manual ⌘V. + @discardableResult + public static func pasteText( + _ text: String, + sourceBundleID: String, + into target: NSRunningApplication? + ) -> Bool { + let item = NSPasteboardItem() + item.setString(text, forType: .string) + item.setString(sourceBundleID, forType: sourceType) + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + return pasteCopiedContent(into: target) + } + + /// Activates `target` and synthesizes ⌘V into it. Assumes the pasteboard already holds what + /// should land there. + private static func pasteCopiedContent(into target: NSRunningApplication?) -> Bool { guard hasAccessibilityPermission else { promptForAccessibilityPermission() return false diff --git a/Sources/DMonteCore/SnippetsController.swift b/Sources/DMonteCore/SnippetsController.swift new file mode 100644 index 0000000..b6a2124 --- /dev/null +++ b/Sources/DMonteCore/SnippetsController.swift @@ -0,0 +1,264 @@ +import AppKit +import Combine + +/// One editing session, handed to the editor sheet as its starting values. `id` is nil for a +/// snippet being created; `editorID` is fresh per session so SwiftUI tears the editor's own +/// `@State` down when the user switches from one snippet to another. +public struct SnippetEditorSession: Sendable { + public let editorID = UUID() + public let id: Snippet.ID? + public let title: String + public let body: String + public let expandsPlaceholders: Bool + + public var isNew: Bool { id == nil } +} + +/// View-model behind the Snippets popover. Owns the library store, the live search and selection, +/// the editing session, and the paste target (the app that was frontmost before the panel +/// appeared). Both the SwiftUI view and the app delegate's key monitor call into it. +@MainActor +public final class SnippetsController: ObservableObject { + /// Also the `SingleInstanceGuard` identifier and the plist `CFBundleIdentifier`; pasted text + /// is stamped with it so clipboard managers can attribute the write. + public static let bundleIdentifier = "com.havokentity.mactools.snippets" + + public let store: SnippetsStore + + @Published public var searchText = "" + @Published public private(set) var selectedID: Snippet.ID? + @Published public private(set) var editorSession: SnippetEditorSession? + /// True when a paste was attempted (or the panel was opened) without Accessibility access. + /// The text still reaches the clipboard, so this drives an explanation rather than a failure. + @Published public private(set) var needsAccessibility = false + /// Bumped each time the panel is summoned so the view can refocus search and scroll to top. + @Published public private(set) var showToken = 0 + + /// The app to paste into — captured by the delegate before the panel takes focus. + public var pasteTarget: NSRunningApplication? + /// Set by the delegate; dismisses the panel (returning focus) right before a paste. + public var onRequestClose: (() -> Void)? + + private var storeObservation: AnyCancellable? + + public init() { + store = SnippetsStore() + // The store is a separate ObservableObject, so its edits would not redraw a view that + // only observes the controller. + storeObservation = store.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + } + + // MARK: - Derived list + + /// Snippets matching the search field, in library order. + public var filteredSnippets: [Snippet] { + SnippetsKit.filter(store.snippets, query: searchText) + } + + public var selectedSnippet: Snippet? { + guard let selectedID else { return nil } + return store.snippet(with: selectedID) + } + + /// Whether the library is empty, as opposed to merely filtered down to nothing — the two need + /// different empty states ("add your first snippet" vs "no matches"). + public var isLibraryEmpty: Bool { + store.snippets.isEmpty + } + + // MARK: - Lifecycle around showing the panel + + public func prepareForShow() { + needsAccessibility = !ClipboardPaste.hasAccessibilityPermission + // An open editor survives a reopen: the panel dismisses on any outside click, and + // discarding half-typed snippet text because the user glanced at another window would + // lose work that has no other copy. + if editorSession == nil { + searchText = "" + selectFirst() + } + showToken &+= 1 + } + + public func selectFirst() { + selectedID = filteredSnippets.first?.id + } + + public func select(_ id: Snippet.ID) { + selectedID = id + } + + public func moveSelection(by delta: Int) { + let items = filteredSnippets + guard !items.isEmpty else { return } + let currentIndex = items.firstIndex { $0.id == selectedID } ?? 0 + let nextIndex = min(max(currentIndex + delta, 0), items.count - 1) + selectedID = items[nextIndex].id + } + + private func ensureSelectionValid() { + let items = filteredSnippets + if let selectedID, items.contains(where: { $0.id == selectedID }) { return } + selectedID = items.first?.id + } + + // MARK: - Pasting + + /// Copies the snippet (placeholders resolved) and pastes it into the app the user came from. + public func paste(_ snippet: Snippet) { + let text = SnippetsKit.pasteText(for: snippet) + let target = pasteTarget + // Dismiss first so focus is back in the target app before ⌘V is synthesized. + onRequestClose?() + let pasted = ClipboardPaste.pasteText(text, sourceBundleID: Self.bundleIdentifier, into: target) + if !pasted { + needsAccessibility = true + } + } + + /// Pastes the highlighted row — the top hit right after typing, since search reselects. + public func pasteSelected() { + guard let selectedSnippet else { return } + paste(selectedSnippet) + } + + // MARK: - Editing + + public func beginCreating() { + editorSession = SnippetEditorSession(id: nil, title: "", body: "", expandsPlaceholders: true) + } + + public func beginEditing(_ id: Snippet.ID) { + guard let snippet = store.snippet(with: id) else { return } + selectedID = id + editorSession = SnippetEditorSession( + id: snippet.id, + title: snippet.title, + body: snippet.body, + expandsPlaceholders: snippet.expandsPlaceholders + ) + } + + public func cancelEditing() { + editorSession = nil + } + + /// Applies the editor's values. A snippet with neither a name nor any text is discarded + /// rather than saved: an empty row can be neither found nor usefully pasted. + public func commitEditor(title: String, body: String, expandsPlaceholders: Bool) { + guard let session = editorSession else { return } + editorSession = nil + + let isBlank = title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + if let id = session.id { + if isBlank { + store.delete(id: id) + } else { + store.update(id: id, title: title, body: body, expandsPlaceholders: expandsPlaceholders) + } + } else if !isBlank { + let created = store.add(title: title, body: body, expandsPlaceholders: expandsPlaceholders) + // A fresh snippet is almost always the one the user wants next, and the search field + // may be filtering it out — clear the query so the new row is actually visible. + searchText = "" + selectedID = created.id + } + + ensureSelectionValid() + } + + public func delete(_ id: Snippet.ID) { + if editorSession?.id == id { + editorSession = nil + } + store.delete(id: id) + ensureSelectionValid() + } + + /// Moves a snippet up (-1) or down (+1) the library. + public func move(_ id: Snippet.ID, by offset: Int) { + guard canMove(id, by: offset) else { return } + store.move(id: id, by: offset) + } + + /// Whether a move in that direction would do anything — drives the enabled state of the + /// reorder buttons, and gates the ⌘↑/⌘↓ shortcuts so both agree. Reordering acts on the whole + /// library, so it is suppressed while a search is hiding the neighbours the row would swap + /// with: the row would appear to sit still while its real position shifted. + public func canMove(_ id: Snippet.ID, by offset: Int) -> Bool { + guard !isSearching, let index = store.snippets.firstIndex(where: { $0.id == id }) else { return false } + return store.snippets.indices.contains(index + offset) + } + + public var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - Accessibility + + public func refreshAccessibilityState() { + needsAccessibility = !ClipboardPaste.hasAccessibilityPermission + } + + // MARK: - Key handling (called from the delegate's local key monitor) + + /// Returns true if the key was consumed; false lets it reach the focused control so the user + /// can keep typing. + public func handleKey(_ event: NSEvent) -> Bool { + // Normalize away Caps Lock so exact modifier checks aren't defeated by it. + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting(.capsLock) + let isCommandOnly = flags == .command + + // While the editor is open every key belongs to it — arrows move the caret, Return + // inserts a newline — except Escape, which cancels the session. + if editorSession != nil { + if event.keyCode == 53 { + cancelEditing() + return true + } + return false + } + + switch Int(event.keyCode) { + case 126 where isCommandOnly: // ⌘↑ move the selected snippet up + if let selectedID { move(selectedID, by: -1) } + return true + case 125 where isCommandOnly: // ⌘↓ move the selected snippet down + if let selectedID { move(selectedID, by: 1) } + return true + case 126: // up arrow + moveSelection(by: -1) + return true + case 125: // down arrow + moveSelection(by: 1) + return true + case 53: // escape + onRequestClose?() + return true + case 36, 76: // return / keypad enter + pasteSelected() + return true + default: + break + } + + if isCommandOnly, let characters = event.charactersIgnoringModifiers { + if characters == "n" { + beginCreating() + return true + } + if characters == "e" { + if let selectedID { beginEditing(selectedID) } + return true + } + } + + return false + } +} diff --git a/Sources/DMonteCore/SnippetsKit.swift b/Sources/DMonteCore/SnippetsKit.swift new file mode 100644 index 0000000..0e36ace --- /dev/null +++ b/Sources/DMonteCore/SnippetsKit.swift @@ -0,0 +1,183 @@ +import Foundation + +/// One saved snippet: a short name the user searches by, and the text that gets pasted. +/// +/// The library is an ordered array, and that order is the user's own (drag-free move up/down), +/// so there is deliberately no sort key here — position in the array *is* the ordering. +public struct Snippet: Identifiable, Codable, Equatable, Sendable { + public let id: UUID + public var title: String + public var body: String + /// Whether `{date}`/`{time}`/`{datetime}` in `body` are replaced at paste time. Per-snippet + /// rather than global so a snippet whose text legitimately contains those braces (a code + /// template, say) can opt out without disabling expansion everywhere. + public var expandsPlaceholders: Bool + public var createdAt: Date + + public init( + id: UUID = UUID(), + title: String, + body: String, + expandsPlaceholders: Bool = true, + createdAt: Date = Date() + ) { + self.id = id + self.title = title + self.body = body + self.expandsPlaceholders = expandsPlaceholders + self.createdAt = createdAt + } + + /// Decoded field-by-field with defaults instead of the synthesized initializer: a library + /// written by a future build that adds a field must still load in an older one, and — more + /// importantly — one unreadable field must not throw away every snippet the user owns. Only + /// `id` is load-bearing; everything else degrades to an empty/sensible value. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + body = try container.decodeIfPresent(String.self, forKey: .body) ?? "" + expandsPlaceholders = try container.decodeIfPresent(Bool.self, forKey: .expandsPlaceholders) ?? true + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + } + + /// The name shown in the list. A snippet saved without a title would otherwise render as a + /// blank row the user cannot aim at, so fall back to the first line of its text. + public var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + let firstLine = body.split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" + let trimmedLine = firstLine.trimmingCharacters(in: .whitespaces) + return trimmedLine.isEmpty ? "Untitled" : trimmedLine + } + + /// One-line flattened preview of the text for the list row. + public var previewText: String { + body + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +/// Pure snippet logic — filtering, placeholder expansion, reordering and the JSON round-trip. +/// Deliberately synchronous, non-isolated and side-effect free apart from the two file helpers, +/// which take an explicit URL so tests never touch the user's Application Support. +public enum SnippetsKit { + /// The complete placeholder vocabulary. This is a *substitution list*, not a templating + /// language — no arguments, no conditionals, no custom formats — because anything richer + /// becomes a syntax the user has to learn and the tool has to document and version. + /// `descriptions` is what the editor shows, so the UI can never drift from what is supported. + public static let placeholders: [(token: String, description: String)] = [ + ("{date}", "Today’s date"), + ("{time}", "The current time"), + ("{datetime}", "Date and time") + ] + + // MARK: - Filtering + + /// Snippets matching `query`, in library order. An empty (or whitespace-only) query returns + /// everything. Matching is case- and diacritic-insensitive across both the title and the + /// body: users search for a phrase they remember writing at least as often as for the name + /// they filed it under. + public static func filter(_ snippets: [Snippet], query: String) -> [Snippet] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return snippets } + + return snippets.filter { snippet in + matches(trimmed, in: snippet.title) || matches(trimmed, in: snippet.body) + } + } + + private static func matches(_ query: String, in text: String) -> Bool { + text.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + + // MARK: - Placeholder expansion + + /// Replaces the documented placeholders in `text` with `date`, rendered in the user's locale + /// and time zone via the shared `.short`/`.medium` styles — a snippet is pasted into a human + /// conversation, so it should read the way that human's Mac writes dates. + /// + /// Unknown braces are left exactly as typed: silently eating `{foo}` would corrupt code and + /// templates that legitimately contain braces, and there is no error channel here to report + /// it through. + public static func expandPlaceholders(in text: String, date: Date = Date(), locale: Locale = .current, timeZone: TimeZone = .current) -> String { + guard text.contains("{") else { return text } + + let dateText = string(from: date, dateStyle: .medium, timeStyle: .none, locale: locale, timeZone: timeZone) + let timeText = string(from: date, dateStyle: .none, timeStyle: .short, locale: locale, timeZone: timeZone) + let dateTimeText = string(from: date, dateStyle: .medium, timeStyle: .short, locale: locale, timeZone: timeZone) + + // {datetime} first: replacing {date} ahead of it would rewrite the "{date" prefix of + // "{datetime}" and leave the stray "time}" behind. + return text + .replacingOccurrences(of: "{datetime}", with: dateTimeText) + .replacingOccurrences(of: "{date}", with: dateText) + .replacingOccurrences(of: "{time}", with: timeText) + } + + /// The text a snippet actually pastes: its body, with placeholders resolved only when the + /// snippet opted in. + public static func pasteText(for snippet: Snippet, date: Date = Date()) -> String { + guard snippet.expandsPlaceholders else { return snippet.body } + return expandPlaceholders(in: snippet.body, date: date) + } + + private static func string( + from date: Date, + dateStyle: DateFormatter.Style, + timeStyle: DateFormatter.Style, + locale: Locale, + timeZone: TimeZone + ) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.dateStyle = dateStyle + formatter.timeStyle = timeStyle + return formatter.string(from: date) + } + + // MARK: - Reordering + + /// `snippets` with the snippet at `index` moved `offset` places (negative = towards the top). + /// Out-of-range indices and moves that would fall off either end return the array unchanged, + /// so the caller can wire this straight to a button without bounds-checking first. + public static func moved(_ snippets: [Snippet], from index: Int, by offset: Int) -> [Snippet] { + guard snippets.indices.contains(index) else { return snippets } + let destination = index + offset + guard snippets.indices.contains(destination), destination != index else { return snippets } + + var reordered = snippets + let moving = reordered.remove(at: index) + reordered.insert(moving, at: destination) + return reordered + } + + /// `snippets` with the snippet identified by `id` moved `offset` places. + public static func moved(_ snippets: [Snippet], id: Snippet.ID, by offset: Int) -> [Snippet] { + guard let index = snippets.firstIndex(where: { $0.id == id }) else { return snippets } + return moved(snippets, from: index, by: offset) + } + + // MARK: - Persistence primitives + + /// JSON for the library, or nil when encoding fails. Nil rather than a throw so the writer + /// can simply skip a bad write instead of surfacing an error the user cannot act on. + public static func encode(_ snippets: [Snippet]) -> Data? { + let encoder = JSONEncoder() + // Stable key order keeps the file diffable and its byte-identical-write check meaningful. + encoder.outputFormatting = [.sortedKeys] + return try? encoder.encode(snippets) + } + + /// The library stored at `url`, or `[]` when the file is absent or unreadable. A missing file + /// is the ordinary first-launch case, not an error worth reporting. + public static func load(from url: URL) -> [Snippet] { + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([Snippet].self, from: data) else { + return [] + } + return decoded + } +} diff --git a/Sources/DMonteCore/SnippetsSizing.swift b/Sources/DMonteCore/SnippetsSizing.swift new file mode 100644 index 0000000..0fa0917 --- /dev/null +++ b/Sources/DMonteCore/SnippetsSizing.swift @@ -0,0 +1,15 @@ +import AppKit + +public enum SnippetsSizing { + public static func preferredSize() -> NSSize { + let scale = currentScale + return NSSize(width: (400 * scale).rounded(), height: (560 * scale).rounded()) + } + + static var currentScale: CGFloat { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let screenScale = visibleFrame.height / 950 + let menuBarScale = NSStatusBar.system.thickness / 26 + return min(1.0, max(0.82, min(screenScale, menuBarScale))) + } +} diff --git a/Sources/DMonteCore/SnippetsStore.swift b/Sources/DMonteCore/SnippetsStore.swift new file mode 100644 index 0000000..1f88b53 --- /dev/null +++ b/Sources/DMonteCore/SnippetsStore.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Owns the snippet library and its JSON file in Application Support. Mutation happens on the +/// main actor; the write is debounced and pushed onto a background executor so typing in the +/// editor never waits on disk. +/// +/// The debounce means the last edits exist only in memory for a moment, and the save task dies +/// with the process — so `flushPendingSave()` must run at termination, exactly as `ClipboardStore` +/// does, or quitting right after "Save" loses the snippet the user just wrote. +@MainActor +public final class SnippetsStore: ObservableObject { + @Published public private(set) var snippets: [Snippet] = [] + + private let directory: URL + private let libraryFileURL: URL + private var saveTask: Task? + private var saveGeneration: UInt64 = 0 + private let writer = SnippetsLibraryWriter() + + /// How long edits are held in memory before the write goes out. Matches `ClipboardStore`: + /// long enough to coalesce a burst of reorder taps, short enough that a crash loses nothing + /// the user would notice. + private static let saveDebounce: UInt64 = 400_000_000 + + public convenience init() { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + self.init(directory: base.appendingPathComponent("DMonteSnippets", isDirectory: true)) + } + + /// Directory-injecting initializer. Internal so tests can point the store at a temp directory + /// and exercise the real save/load path without touching the user's Application Support. + init(directory: URL) { + self.directory = directory + libraryFileURL = directory.appendingPathComponent("snippets.json") + + // Snippets routinely hold private boilerplate — addresses, account numbers, signatures — + // so lock the storage to the current user (0700). The POSIX attribute only applies to + // directories createDirectory actually creates, so re-assert it for a pre-existing one. + let ownerOnly: [FileAttributeKey: Any] = [.posixPermissions: 0o700] + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: ownerOnly + ) + try? FileManager.default.setAttributes(ownerOnly, ofItemAtPath: directory.path) + snippets = SnippetsKit.load(from: libraryFileURL) + } + + // MARK: - Public API + + public func snippet(with id: Snippet.ID) -> Snippet? { + snippets.first { $0.id == id } + } + + /// Appends a new snippet and returns it, so the caller can select the row it just created. + @discardableResult + public func add(title: String, body: String, expandsPlaceholders: Bool) -> Snippet { + let snippet = Snippet( + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + body: body, + expandsPlaceholders: expandsPlaceholders + ) + snippets.append(snippet) + scheduleSave() + return snippet + } + + public func update(id: Snippet.ID, title: String, body: String, expandsPlaceholders: Bool) { + guard let index = snippets.firstIndex(where: { $0.id == id }) else { return } + snippets[index].title = title.trimmingCharacters(in: .whitespacesAndNewlines) + snippets[index].body = body + snippets[index].expandsPlaceholders = expandsPlaceholders + scheduleSave() + } + + public func delete(id: Snippet.ID) { + guard let index = snippets.firstIndex(where: { $0.id == id }) else { return } + snippets.remove(at: index) + scheduleSave() + } + + /// Moves a snippet `offset` places in the library. A move that would fall off either end is a + /// no-op and is not persisted, so hammering the up arrow on the top row writes nothing. + public func move(id: Snippet.ID, by offset: Int) { + let reordered = SnippetsKit.moved(snippets, id: id, by: offset) + guard reordered != snippets else { return } + snippets = reordered + scheduleSave() + } + + /// Writes the debounced save immediately. Call from `applicationWillTerminate`: the save task + /// is detached and dies with the process, so without this the last few hundred milliseconds of + /// edits never reach disk. A no-op once the pending generation is already on disk. + public func flushPendingSave() { + saveTask?.cancel() + saveTask = nil + writer.write(snippets, to: libraryFileURL, generation: saveGeneration) + } + + // MARK: - Private + + private func scheduleSave() { + saveTask?.cancel() + saveGeneration &+= 1 + let generation = saveGeneration + let snapshot = snippets + let url = libraryFileURL + let writer = self.writer + // Task.detached takes a @Sendable closure, so it does NOT inherit this method's + // @MainActor isolation — the encode and write run on a background executor. + saveTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: Self.saveDebounce) + if Task.isCancelled { return } + writer.write(snapshot, to: url, generation: generation) + } + } +} + +/// Serializes library writes and drops any snapshot older than one already on disk. Debounced +/// save tasks are independent and unordered: a large snapshot can still be encoding when a +/// smaller, newer one — a deletion, say — is written, and its atomic rename would then land last +/// and resurrect the deleted snippet. The generation is claimed inside the queue, so the flush at +/// termination cannot race an in-flight write either. Encoding stays outside the queue; only the +/// write itself is serialized. +private final class SnippetsLibraryWriter: @unchecked Sendable { + private let queue = DispatchQueue(label: "com.havokentity.mactools.snippets.library-write", qos: .utility) + private var lastWrittenGeneration: UInt64 = 0 + + func write(_ snippets: [Snippet], to url: URL, generation: UInt64) { + guard let data = SnippetsKit.encode(snippets) else { return } + queue.sync { + guard generation > lastWrittenGeneration else { return } + lastWrittenGeneration = generation + try? data.write(to: url, options: .atomic) + // The library holds the user's own private boilerplate; restrict it to the owner. + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + } +} diff --git a/Sources/DMonteCore/SnippetsView.swift b/Sources/DMonteCore/SnippetsView.swift new file mode 100644 index 0000000..b0ac1e4 --- /dev/null +++ b/Sources/DMonteCore/SnippetsView.swift @@ -0,0 +1,553 @@ +import AppKit +import SwiftUI + +/// The floating Snippets popover: a search field, the snippet library, an editor sheet and a +/// settings overlay. Clicking a snippet pastes it into whatever app the user came from. Content +/// is scaled to the menu-bar/display scale so it fits the scaled panel (as in the other tools). +public struct SnippetsPopoverView: View { + @ObservedObject var controller: SnippetsController + var onQuit: () -> Void + + @State private var isShowingSettings = false + @FocusState private var searchFocused: Bool + private let scale = SnippetsSizing.currentScale + + public init(controller: SnippetsController, onQuit: @escaping () -> Void) { + self.controller = controller + self.onQuit = onQuit + } + + private func s(_ value: CGFloat) -> CGFloat { value * scale } + + public var body: some View { + ZStack { + VStack(spacing: 0) { + header + searchField + if controller.needsAccessibility { + accessibilityNotice + } + Divider().opacity(0.6) + snippetList + Divider().opacity(0.6) + footer + } + + if let session = controller.editorSession { + PreferencesOverlay(cornerRadius: 18) { + SnippetEditorView( + session: session, + scale: scale, + onSave: { title, body, expands in + controller.commitEditor(title: title, body: body, expandsPlaceholders: expands) + }, + onDelete: session.id.map { id in + { controller.delete(id) } + }, + onCancel: { controller.cancelEditing() } + ) + // A fresh identity per session so the editor's own @State starts from the + // snippet being opened instead of keeping the previous one's text. + .id(session.editorID) + } + } + + if isShowingSettings { + PreferencesOverlay(cornerRadius: 18) { + SnippetsSettingsView( + controller: controller, + onQuit: onQuit, + onClose: { isShowingSettings = false } + ) + } + } + } + .frame(width: SnippetsSizing.preferredSize().width, height: SnippetsSizing.preferredSize().height) + .frostedPanel(cornerRadius: 18) + .onAppear { searchFocused = true } + .onChange(of: controller.showToken) { _, _ in + searchFocused = true + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: s(8)) { + Image(systemName: "note.text") + .font(.system(size: s(15), weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Text("Snippets") + .font(.system(size: s(15), weight: .bold)) + .foregroundStyle(.primary.opacity(0.9)) + + Spacer() + + Button { + controller.beginCreating() + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: s(15), weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help("New snippet (⌘N)") + + Button { + isShowingSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: s(14), weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Settings") + } + .padding(.horizontal, s(16)) + .padding(.top, s(14)) + .padding(.bottom, s(10)) + } + + // MARK: - Search + + private var searchField: some View { + HStack(spacing: s(7)) { + Image(systemName: "magnifyingglass") + .font(.system(size: s(12), weight: .semibold)) + .foregroundStyle(.secondary) + + TextField("Search snippets", text: $controller.searchText) + .textFieldStyle(.plain) + .font(.system(size: s(13))) + .focused($searchFocused) + .onChange(of: controller.searchText) { _, _ in + controller.selectFirst() + } + + if !controller.searchText.isEmpty { + Button { + controller.searchText = "" + controller.selectFirst() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: s(12))) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, s(10)) + .frame(height: s(32)) + .background( + RoundedRectangle(cornerRadius: s(8), style: .continuous) + .fill(Color.primary.opacity(0.07)) + ) + .padding(.horizontal, s(14)) + .padding(.bottom, s(8)) + } + + // MARK: - Accessibility notice + + /// Without Accessibility the ⌘V can't be synthesized, so a click copies but does not paste. + /// Saying so up front beats letting the user click a snippet into a window that never changes. + private var accessibilityNotice: some View { + HStack(spacing: s(7)) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: s(11), weight: .semibold)) + .foregroundStyle(.orange) + + Text("Snippets are copied, not pasted — Accessibility access is off.") + .font(.system(size: s(10.5))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button("Fix") { + openAccessibilitySettings() + } + .font(.system(size: s(10.5), weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + } + .padding(.horizontal, s(9)) + .padding(.vertical, s(6)) + .background(RoundedRectangle(cornerRadius: s(8), style: .continuous).fill(Color.orange.opacity(0.12))) + .padding(.horizontal, s(14)) + .padding(.bottom, s(8)) + } + + // MARK: - List + + private var snippetList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: s(3)) { + let snippets = controller.filteredSnippets + if snippets.isEmpty { + emptyState + } else { + ForEach(snippets) { snippet in + SnippetRow( + snippet: snippet, + isSelected: snippet.id == controller.selectedID, + canMoveUp: controller.canMove(snippet.id, by: -1), + canMoveDown: controller.canMove(snippet.id, by: 1), + scale: scale, + onPaste: { controller.paste(snippet) }, + onEdit: { controller.beginEditing(snippet.id) }, + onMoveUp: { controller.move(snippet.id, by: -1) }, + onMoveDown: { controller.move(snippet.id, by: 1) }, + onDelete: { controller.delete(snippet.id) } + ) + .id(snippet.id) + } + } + } + .padding(.horizontal, s(8)) + .padding(.vertical, s(6)) + } + .onChange(of: controller.selectedID) { _, newValue in + guard let newValue else { return } + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + .frame(maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: s(8)) { + Image(systemName: controller.isLibraryEmpty ? "note.text.badge.plus" : "magnifyingglass") + .font(.system(size: s(30), weight: .light)) + .foregroundStyle(.secondary) + + Text(controller.isLibraryEmpty ? "No snippets yet" : "No matches") + .font(.system(size: s(13), weight: .medium)) + .foregroundStyle(.secondary) + + if controller.isLibraryEmpty { + Button { + controller.beginCreating() + } label: { + Label("New Snippet", systemImage: "plus") + .font(.system(size: s(12), weight: .semibold)) + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, s(40)) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: s(10)) { + footerHint("⏎", "Paste") + footerHint("⌘N", "New") + footerHint("⌘E", "Edit") + Spacer() + footerHint("⌘↑↓", "Reorder") + } + .padding(.horizontal, s(14)) + .padding(.top, s(6)) + .padding(.bottom, s(10)) + } + + private func footerHint(_ key: String, _ label: String) -> some View { + HStack(spacing: s(3)) { + Text(key) + .font(.system(size: s(9), weight: .bold)) + .foregroundStyle(.secondary) + .padding(.horizontal, s(4)) + .padding(.vertical, s(1)) + .background(RoundedRectangle(cornerRadius: s(3)).fill(Color.primary.opacity(0.08))) + Text(label) + .font(.system(size: s(9))) + .foregroundStyle(.secondary) + } + } +} + +/// Opens the Accessibility pane, prompting first so DMonte Snippets is already listed there. +@MainActor +private func openAccessibilitySettings() { + ClipboardPaste.promptForAccessibilityPermission() + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } +} + +// MARK: - Row + +private struct SnippetRow: View { + var snippet: Snippet + var isSelected: Bool + var canMoveUp: Bool + var canMoveDown: Bool + var scale: CGFloat + var onPaste: () -> Void + var onEdit: () -> Void + var onMoveUp: () -> Void + var onMoveDown: () -> Void + var onDelete: () -> Void + + @State private var isHovered = false + + private func s(_ value: CGFloat) -> CGFloat { value * scale } + + var body: some View { + HStack(spacing: s(10)) { + RoundedRectangle(cornerRadius: s(6), style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .frame(width: s(28), height: s(28)) + .overlay( + Image(systemName: snippet.expandsPlaceholders && snippet.body.contains("{") ? "calendar.badge.clock" : "text.alignleft") + .font(.system(size: s(12), weight: .semibold)) + .foregroundStyle(Color.accentColor) + ) + + VStack(alignment: .leading, spacing: s(1)) { + Text(snippet.displayTitle) + .font(.system(size: s(12.5), weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(snippet.previewText) + .font(.system(size: s(10))) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: s(4)) + + if isHovered { + rowActions + } + } + .padding(.horizontal, s(8)) + .padding(.vertical, s(6)) + .background( + RoundedRectangle(cornerRadius: s(8), style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.22) : (isHovered ? Color.primary.opacity(0.06) : Color.clear)) + ) + .overlay( + RoundedRectangle(cornerRadius: s(8), style: .continuous) + .strokeBorder(Color.accentColor.opacity(isSelected ? 0.5 : 0), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous)) + .onHover { isHovered = $0 } + .onTapGesture { onPaste() } + .contextMenu { + Button("Paste") { onPaste() } + Button("Edit…") { onEdit() } + Divider() + Button("Move Up") { onMoveUp() }.disabled(!canMoveUp) + Button("Move Down") { onMoveDown() }.disabled(!canMoveDown) + Divider() + Button("Delete", role: .destructive) { onDelete() } + } + .help("Click to paste into the app you came from") + } + + private var rowActions: some View { + HStack(spacing: s(2)) { + rowButton(systemImage: "chevron.up", help: "Move up", isEnabled: canMoveUp, action: onMoveUp) + rowButton(systemImage: "chevron.down", help: "Move down", isEnabled: canMoveDown, action: onMoveDown) + rowButton(systemImage: "pencil", help: "Edit", isEnabled: true, action: onEdit) + rowButton(systemImage: "trash", help: "Delete", isEnabled: true, action: onDelete) + } + } + + private func rowButton(systemImage: String, help: String, isEnabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: s(11))) + .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.35)) + .frame(width: s(18), height: s(18)) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .help(help) + } +} + +// MARK: - Editor + +private struct SnippetEditorView: View { + var session: SnippetEditorSession + var scale: CGFloat + var onSave: (String, String, Bool) -> Void + var onDelete: (() -> Void)? + var onCancel: () -> Void + + @State private var title: String + /// The snippet's text. Named `text`, not `body`: a stored `body` would collide with the + /// `View` protocol's own `body` requirement. + @State private var text: String + @State private var expandsPlaceholders: Bool + @FocusState private var titleFocused: Bool + + init( + session: SnippetEditorSession, + scale: CGFloat, + onSave: @escaping (String, String, Bool) -> Void, + onDelete: (() -> Void)?, + onCancel: @escaping () -> Void + ) { + self.session = session + self.scale = scale + self.onSave = onSave + self.onDelete = onDelete + self.onCancel = onCancel + _title = State(initialValue: session.title) + _text = State(initialValue: session.body) + _expandsPlaceholders = State(initialValue: session.expandsPlaceholders) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(session.isNew ? "New Snippet" : "Edit Snippet") + .font(.system(size: 16, weight: .bold)) + + Spacer() + + Button { + onCancel() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + TextField("Name", text: $title) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + .focused($titleFocused) + + TextEditor(text: $text) + .font(.system(size: 12, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(6) + .frame(height: 140) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.06))) + .overlay( + RoundedRectangle(cornerRadius: 8).strokeBorder(Color.primary.opacity(0.12), lineWidth: 1) + ) + + HStack { + Text("Insert date & time") + .font(.system(size: 13, weight: .semibold)) + Spacer() + GreenSwitch(isOn: $expandsPlaceholders) + } + + Text(placeholderHelp) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + if let onDelete { + Button(role: .destructive) { + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + } + + Spacer() + + Button("Cancel") { onCancel() } + + // ⌘S rather than the default Return action: snippets are multi-line, and a + // Return-to-save button would swallow every newline typed in the editor. + Button("Save") { onSave(title, text, expandsPlaceholders) } + .keyboardShortcut("s", modifiers: .command) + .buttonStyle(.borderedProminent) + .help("Save (⌘S)") + } + } + .padding(20) + .frame(width: 340) + .onAppear { titleFocused = true } + } + + + /// The supported tokens, read straight off `SnippetsKit.placeholders` so the help text cannot + /// drift from what expansion actually replaces. + private var placeholderHelp: String { + let tokens = SnippetsKit.placeholders + .map { "\($0.token) — \($0.description.lowercased())" } + .joined(separator: ", ") + return "When on, these are replaced as the snippet is pasted: \(tokens). Anything else in braces is pasted unchanged." + } +} + +// MARK: - Settings + +private struct SnippetsSettingsView: View { + @ObservedObject var controller: SnippetsController + var onQuit: () -> Void + var onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Snippets Settings") + .font(.system(size: 16, weight: .bold)) + Spacer() + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + Text("Click a snippet to paste it into the app you were last working in. Snippets are stored on this Mac only, readable by you alone.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if controller.needsAccessibility { + VStack(alignment: .leading, spacing: 8) { + Text("Pasting needs Accessibility access") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + Text("Allow DMonte Snippets under Privacy & Security → Accessibility so it can paste into other apps. Without it a snippet is still copied to the clipboard.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Open Accessibility Settings") { + openAccessibilitySettings() + } + .font(.system(size: 12, weight: .medium)) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.orange.opacity(0.12))) + } + + Spacer(minLength: 0) + + Button(role: .destructive) { + onClose() + onQuit() + } label: { + Label("Quit Snippets", systemImage: "power") + } + } + .padding(20) + .frame(width: 340, height: controller.needsAccessibility ? 340 : 230) + .onAppear { controller.refreshAccessibilityState() } + } +} diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift index 8d1039b..dc68660 100644 --- a/Sources/DMonteCore/ToolboxCatalog.swift +++ b/Sources/DMonteCore/ToolboxCatalog.swift @@ -64,7 +64,8 @@ public enum ToolboxCatalog { ToolboxTool(id: "colorPicker", title: "Color Picker", iconName: "eyedropper.halffull", tint: .mint, bundleID: prefix + "colorpicker", appName: "DMonte Color Picker.app", executableName: "DMonteColorPicker", arguments: ["--open"]), ToolboxTool(id: "grabText", title: "Grab Text", iconName: "text.viewfinder", tint: contrastGreen, bundleID: prefix + "grabtext", appName: "DMonte Grab Text.app", executableName: "DMonteGrabText", arguments: ["--open"]), ToolboxTool(id: "focusTimer", title: "Focus Timer", iconName: "timer", tint: .red, bundleID: prefix + "focustimer", appName: "DMonte Focus Timer.app", executableName: "DMonteFocusTimer", arguments: ["--open"]), - ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]) + ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]), + ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"]) ] } diff --git a/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift b/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift new file mode 100644 index 0000000..f9c329c --- /dev/null +++ b/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift @@ -0,0 +1,133 @@ +import AppKit +import DMonteCore +import OSLog +import SwiftUI + +/// Distributed notification used to reveal this helper's popover when the Toolbox (or a second +/// launch with `--open`) asks for it. +enum SnippetsNotifications { + static let showWindow = Notification.Name("com.havokentity.mactools.snippets.showWindow") +} + +@MainActor +final class SnippetsAppDelegate: NSObject, NSApplicationDelegate { + private let controller = SnippetsController() + + private var statusItem: HelperStatusItem? + private var panelHost: HelperPanelHost? + private var keyMonitor: Any? + + /// History of app activations so a pasted snippet lands in the app the user was typing in + /// before the status-item click — with separate Spaces that click spuriously re-activates the + /// topmost app on the panel's display, sometimes *before* `onWillShow` runs. The tracker owns + /// the skip logic and freezes the history while the panel is open; see `ActivationTracker`. + /// Only our own activations are excluded — pasting into another helper is legitimate. + private let activationTracker: ActivationTracker + + private static let log = Logger(subsystem: SnippetsController.bundleIdentifier, category: "paste-target") + + override init() { + activationTracker = ActivationTracker( + logger: SnippetsAppDelegate.log, + excluding: { $0.bundleIdentifier == SnippetsController.bundleIdentifier } + ) + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + AppDefaults.registerDefaults() + + controller.onRequestClose = { [weak self] in + self?.panelHost?.close() + } + + configurePanelHost() + configureStatusItem() + panelHost?.observeShowNotification(named: SnippetsNotifications.showWindow) + } + + func applicationWillTerminate(_ notification: Notification) { + // Library saves are debounced; the task that would perform them dies with us. + controller.store.flushPendingSave() + panelHost?.stopObservingShowNotifications() + activationTracker.stopObserving() + panelHost?.removeOutsideClickMonitor() + removeKeyMonitor() + panelHost?.dismissForTermination() + statusItem?.remove() + } + + // MARK: - Setup + + private func configurePanelHost() { + // The panel takes keyboard focus so the user can type to search; the previous app is + // re-activated right before the synthetic ⌘V. + let host = HelperPanelHost( + configuration: HelperPanelHost.Configuration( + sizing: .preferred({ SnippetsSizing.preferredSize() }) + ), + content: .viewController({ [controller, weak self] in + NSHostingController( + rootView: SnippetsPopoverView(controller: controller, onQuit: { self?.quit() }) + ) + }), + anchorView: { [weak self] in self?.statusItem?.button } + ) + host.onWillShow = { [weak self] in + guard let self else { return } + // The app to paste into is whatever the user was working in just before we + // appeared — resolved from the activation history, then frozen for this session. + let target = self.activationTracker.resolveTarget() ?? NSWorkspace.shared.frontmostApplication + self.activationTracker.beginSession() + self.controller.pasteTarget = target + Self.log.info("paste target: \(target?.localizedName ?? "none", privacy: .public)") + self.controller.prepareForShow() + } + host.onDidShow = { [weak self] in + self?.installKeyMonitor() + } + host.onDidClose = { [weak self] in + self?.activationTracker.endSession() + self?.removeKeyMonitor() + } + panelHost = host + host.configure() + } + + private func configureStatusItem() { + statusItem = HelperStatusItem( + image: Self.statusIcon(), + toolTip: "Snippets", + primaryAction: { [weak self] in self?.panelHost?.toggle() }, + quitAction: { [weak self] in self?.quit() } + ) + } + + private static func statusIcon() -> NSImage { + let image = NSImage(systemSymbolName: "note.text", accessibilityDescription: "Snippets") ?? NSImage() + image.isTemplate = true + return image + } + + // MARK: - Key monitor + + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + return self.controller.handleKey(event) ? nil : event + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + self.keyMonitor = nil + } + } + + private func quit() { + panelHost?.close() + NSApp.terminate(nil) + } +} diff --git a/Sources/DMonteSnippetsApp/main.swift b/Sources/DMonteSnippetsApp/main.swift new file mode 100644 index 0000000..95d4e42 --- /dev/null +++ b/Sources/DMonteSnippetsApp/main.swift @@ -0,0 +1,24 @@ +import AppKit +import DMonteCore + +let singleInstanceGuard = SingleInstanceGuard(identifier: "com.havokentity.mactools.snippets") + +guard singleInstanceGuard.isPrimary else { + if CommandLine.arguments.contains("--open") { + DistributedNotificationCenter.default().postNotificationName( + SnippetsNotifications.showWindow, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + exit(EXIT_SUCCESS) +} + +let app = NSApplication.shared +let delegate = SnippetsAppDelegate() + +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/Tests/DMonteCoreTests/SnippetsKitTests.swift b/Tests/DMonteCoreTests/SnippetsKitTests.swift new file mode 100644 index 0000000..f6fa8a1 --- /dev/null +++ b/Tests/DMonteCoreTests/SnippetsKitTests.swift @@ -0,0 +1,285 @@ +import Foundation +import XCTest +@testable import DMonteCore + +/// Covers the three things Snippets cannot get wrong: which snippets a search shows, what a +/// snippet actually pastes once placeholders are resolved, and that the library survives a quit. +/// Persistence is driven through a real `SnippetsStore` pointed at a temp directory, so the save +/// debounce, the flush-at-terminate path and the on-disk format are all exercised for real — +/// never the user's own Application Support. +final class SnippetsKitTests: XCTestCase { + + private func makeSnippet(_ title: String, _ body: String, expands: Bool = true) -> Snippet { + Snippet(title: title, body: body, expandsPlaceholders: expands) + } + + // MARK: - Filtering + + func testEmptyQueryReturnsEveryStoredSnippet() { + let snippets = [makeSnippet("Address", "12 Baker St"), makeSnippet("Signature", "Regards")] + + XCTAssertEqual(SnippetsKit.filter(snippets, query: "").count, 2) + XCTAssertEqual(SnippetsKit.filter(snippets, query: " ").count, 2) + } + + func testFilterMatchesTheTitleCaseInsensitively() { + let snippets = [makeSnippet("Address", "12 Baker St"), makeSnippet("Signature", "Regards")] + + let matches = SnippetsKit.filter(snippets, query: "ADDR") + + XCTAssertEqual(matches.map(\.title), ["Address"]) + } + + /// Users search for a phrase they remember writing at least as often as for the name they + /// filed it under, so the body is searched too. + func testFilterMatchesTheBody() { + let snippets = [makeSnippet("Address", "12 Baker St"), makeSnippet("Signature", "Kind regards")] + + let matches = SnippetsKit.filter(snippets, query: "baker") + + XCTAssertEqual(matches.map(\.title), ["Address"]) + } + + /// Filtering must not reshuffle: the list order is the user's own manual ordering, and the + /// Return key pastes whatever ends up first. + func testFilterPreservesLibraryOrder() { + let snippets = [makeSnippet("One", "alpha"), makeSnippet("Two", "alpha"), makeSnippet("Three", "alpha")] + + let matches = SnippetsKit.filter(snippets, query: "alpha") + + XCTAssertEqual(matches.map(\.title), ["One", "Two", "Three"]) + } + + func testFilterReturnsNothingWhenNoSnippetMatches() { + let snippets = [makeSnippet("Address", "12 Baker St")] + + XCTAssertTrue(SnippetsKit.filter(snippets, query: "invoice").isEmpty) + } + + // MARK: - Placeholder expansion + + private var fixedDate: Date { + // 2026-07-18 09:05:00 UTC — a value with distinct date and time components, so a + // placeholder substituted with the wrong one is visible in the assertion. + var components = DateComponents() + components.year = 2026 + components.month = 7 + components.day = 18 + components.hour = 9 + components.minute = 5 + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + return calendar.date(from: components) ?? Date(timeIntervalSince1970: 0) + } + + private func expand(_ text: String) -> String { + SnippetsKit.expandPlaceholders( + in: text, + date: fixedDate, + locale: Locale(identifier: "en_US_POSIX"), + timeZone: TimeZone(identifier: "UTC") ?? .gmt + ) + } + + func testDatePlaceholderIsReplaced() { + let expanded = expand("Filed on {date}.") + + XCTAssertFalse(expanded.contains("{date}")) + XCTAssertTrue(expanded.contains("2026"), "expected a rendered date, got \(expanded)") + } + + func testTimePlaceholderIsReplaced() { + let expanded = expand("Standup at {time}.") + + XCTAssertFalse(expanded.contains("{time}")) + XCTAssertTrue(expanded.contains("9:05"), "expected a rendered time, got \(expanded)") + } + + /// `{datetime}` must be substituted before `{date}`: replacing `{date}` first rewrites the + /// "{date" prefix and leaves a stray "time}" behind. + func testDateTimePlaceholderIsNotShreddedByTheDateReplacement() { + let expanded = expand("{datetime}") + + XCTAssertFalse(expanded.contains("time}"), "left a stray token behind: \(expanded)") + XCTAssertTrue(expanded.contains("2026")) + XCTAssertTrue(expanded.contains("9:05")) + } + + /// Unknown braces are pasted verbatim — silently eating them would corrupt code templates, + /// and there is no error channel to report a "bad placeholder" through. + func testUnknownPlaceholdersArePastedUnchanged() { + XCTAssertEqual(expand("let x = {foo};"), "let x = {foo};") + } + + func testTextWithoutBracesIsReturnedUnchanged() { + XCTAssertEqual(expand("Kind regards"), "Kind regards") + } + + func testPasteTextRespectsThePerSnippetOptOut() { + let expanding = makeSnippet("Log", "Filed {date}", expands: true) + let literal = makeSnippet("Template", "Filed {date}", expands: false) + + XCTAssertNotEqual(SnippetsKit.pasteText(for: expanding, date: fixedDate), "Filed {date}") + XCTAssertEqual(SnippetsKit.pasteText(for: literal, date: fixedDate), "Filed {date}") + } + + /// The editor's help text is generated from this list, so an undocumented token would be a + /// token the user can never discover. + func testEveryDocumentedPlaceholderIsActuallyExpanded() { + for placeholder in SnippetsKit.placeholders { + XCTAssertNotEqual( + expand(placeholder.token), placeholder.token, + "\(placeholder.token) is documented in the UI but is not substituted" + ) + } + } + + // MARK: - Reordering + + func testMovedShiftsTheSnippetWithinTheLibrary() { + let snippets = [makeSnippet("A", "a"), makeSnippet("B", "b"), makeSnippet("C", "c")] + + XCTAssertEqual(SnippetsKit.moved(snippets, from: 2, by: -1).map(\.title), ["A", "C", "B"]) + XCTAssertEqual(SnippetsKit.moved(snippets, from: 0, by: 1).map(\.title), ["B", "A", "C"]) + } + + /// Wired straight to a button, so a move off either end must be a no-op rather than a crash. + func testMovedIgnoresOutOfRangeMoves() { + let snippets = [makeSnippet("A", "a"), makeSnippet("B", "b")] + + XCTAssertEqual(SnippetsKit.moved(snippets, from: 0, by: -1).map(\.title), ["A", "B"]) + XCTAssertEqual(SnippetsKit.moved(snippets, from: 1, by: 1).map(\.title), ["A", "B"]) + XCTAssertEqual(SnippetsKit.moved(snippets, from: 7, by: 1).map(\.title), ["A", "B"]) + } + + func testMovedByIDFindsTheRightRow() { + let snippets = [makeSnippet("A", "a"), makeSnippet("B", "b"), makeSnippet("C", "c")] + + let reordered = SnippetsKit.moved(snippets, id: snippets[0].id, by: 2) + + XCTAssertEqual(reordered.map(\.title), ["B", "C", "A"]) + } + + // MARK: - Persistence + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("SnippetsKitTests-\(UUID().uuidString)", isDirectory: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + func testEncodeDecodeRoundTripPreservesEveryField() throws { + let original = [ + makeSnippet("Address", "12 Baker St\nLondon", expands: false), + makeSnippet("Log", "Filed {date}", expands: true) + ] + let url = try makeTemporaryDirectory() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + let fileURL = url.appendingPathComponent("snippets.json") + + let data = try XCTUnwrap(SnippetsKit.encode(original)) + try data.write(to: fileURL) + + XCTAssertEqual(SnippetsKit.load(from: fileURL), original) + } + + /// First launch: no file yet. That is the ordinary case, not an error, so it must load as an + /// empty library rather than blowing up. + func testLoadingAMissingFileYieldsAnEmptyLibrary() throws { + let url = try makeTemporaryDirectory() + + XCTAssertTrue(SnippetsKit.load(from: url.appendingPathComponent("snippets.json")).isEmpty) + } + + /// A truncated or hand-mangled file must not take the tool down with it. + func testLoadingCorruptJSONYieldsAnEmptyLibrary() throws { + let url = try makeTemporaryDirectory() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + let fileURL = url.appendingPathComponent("snippets.json") + try Data("{ not json".utf8).write(to: fileURL) + + XCTAssertTrue(SnippetsKit.load(from: fileURL).isEmpty) + } + + /// A library written by a future build that adds a field must still load here, and a snippet + /// missing the expansion flag must default to expanding rather than vanishing. + func testDecodingToleratesMissingAndUnknownFields() throws { + let json = """ + [{"id":"\(UUID().uuidString)","title":"Address","body":"12 Baker St","futureField":42}] + """ + let decoded = try JSONDecoder().decode([Snippet].self, from: Data(json.utf8)) + + XCTAssertEqual(decoded.count, 1) + XCTAssertEqual(decoded.first?.title, "Address") + XCTAssertTrue(try XCTUnwrap(decoded.first).expandsPlaceholders) + } + + /// The save is debounced and its task dies with the process, so a quit straight after an edit + /// only keeps the snippet because `flushPendingSave()` runs at termination. This is the whole + /// "survives quit" contract, driven through the real store. + @MainActor + func testLibrarySurvivesAQuitViaFlushPendingSave() throws { + let directory = try makeTemporaryDirectory() + + let store = SnippetsStore(directory: directory) + store.add(title: "Address", body: "12 Baker St", expandsPlaceholders: false) + store.add(title: "Log", body: "Filed {date}", expandsPlaceholders: true) + store.flushPendingSave() + + let reopened = SnippetsStore(directory: directory) + + XCTAssertEqual(reopened.snippets.map(\.title), ["Address", "Log"]) + XCTAssertEqual(reopened.snippets.first?.expandsPlaceholders, false) + } + + @MainActor + func testEditsAndDeletionsSurviveAReopen() throws { + let directory = try makeTemporaryDirectory() + + let store = SnippetsStore(directory: directory) + let keep = store.add(title: "Keep", body: "keep me", expandsPlaceholders: true) + let drop = store.add(title: "Drop", body: "drop me", expandsPlaceholders: true) + store.update(id: keep.id, title: "Kept", body: "edited", expandsPlaceholders: true) + store.delete(id: drop.id) + store.flushPendingSave() + + let reopened = SnippetsStore(directory: directory) + + XCTAssertEqual(reopened.snippets.map(\.title), ["Kept"]) + XCTAssertEqual(reopened.snippets.first?.body, "edited") + } + + /// Reordering is persisted too — a library that re-sorts itself on the next launch would make + /// the manual ordering pointless. + @MainActor + func testReorderSurvivesAReopen() throws { + let directory = try makeTemporaryDirectory() + + let store = SnippetsStore(directory: directory) + store.add(title: "A", body: "a", expandsPlaceholders: true) + let second = store.add(title: "B", body: "b", expandsPlaceholders: true) + store.move(id: second.id, by: -1) + store.flushPendingSave() + + let reopened = SnippetsStore(directory: directory) + + XCTAssertEqual(reopened.snippets.map(\.title), ["B", "A"]) + } + + /// The store is the only writer, and a stale snapshot landing after a newer one would + /// resurrect deleted snippets — so a flush must never regress what is already on disk. + @MainActor + func testFlushAfterNoFurtherEditsDoesNotRegressTheFile() throws { + let directory = try makeTemporaryDirectory() + + let store = SnippetsStore(directory: directory) + let snippet = store.add(title: "A", body: "a", expandsPlaceholders: true) + store.flushPendingSave() + store.delete(id: snippet.id) + store.flushPendingSave() + store.flushPendingSave() + + XCTAssertTrue(SnippetsStore(directory: directory).snippets.isEmpty) + } +} From 6f0109c78e71299597fc8db69b5794860e6e9b48 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:26:15 +0530 Subject: [PATCH 2/5] Snippets: keep focus in the editor and stop the row labels reading whole documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening the panel with the editor still open stole focus back to the search field. The editor deliberately survives a reopen so half-typed snippet text is not discarded, but its overlay covers the search field, so the unconditional refocus on showToken sent the user's next keystrokes into a control they could not see — silently filtering the list instead of typing the snippet. The panel is only ordered out, never torn down, so simply not refocusing while an editor session exists restores the editor field the window already held as first responder. The list row's date badge was driven off body.contains("{"), which is a different question from the one the badge answers: a snippet whose only braces are {foo} expands to itself, so a pure code template was badged as date-bearing while pasting byte-for-byte what was typed. Both the badge and the expansion fast path now go through SnippetsKit.containsPlaceholder, and Snippet.pastesADifferentValueEachTime names the property the badge actually wants, so icon and pasteText(for:) cannot drift apart. A test asserts the two agree for expanding, opted-out, unknown-token and brace-free snippets. displayTitle split the entire body into lines to read the first one and previewText copied the whole body to flatten it, both on every row render, for two lineLimit(1) labels. A snippet body is whatever the user pasted into the editor, so that is unbounded work — and a megabyte-long string handed to a single-line label is laid out in full. Both now read through a bounded previewSource that skips leading blank space and stops at 200 characters. Skipping leading blank space also fixes a snippet whose text begins with an empty line rendering as "Untitled". Also corrects the .sortedKeys comment, which justified itself with a byte-identical-write check that neither this store nor ClipboardStore has. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/SnippetsKit.swift | 42 +++++++++++++--- Sources/DMonteCore/SnippetsView.swift | 8 ++- Tests/DMonteCoreTests/SnippetsKitTests.swift | 53 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/Sources/DMonteCore/SnippetsKit.swift b/Sources/DMonteCore/SnippetsKit.swift index 0e36ace..b16c657 100644 --- a/Sources/DMonteCore/SnippetsKit.swift +++ b/Sources/DMonteCore/SnippetsKit.swift @@ -41,21 +41,39 @@ public struct Snippet: Identifiable, Codable, Equatable, Sendable { createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } + /// Whether pasting this snippet resolves a placeholder, i.e. whether its text depends on when + /// it is pasted. The list row badges these differently, and the badge is driven off this + /// rather than off "has braces" so the icon and `pasteText(for:)` cannot disagree. + public var pastesADifferentValueEachTime: Bool { + expandsPlaceholders && SnippetsKit.containsPlaceholder(body) + } + + /// How much of the body the two single-line row labels ever look at. A snippet body is + /// whatever the user pasted into the editor — potentially a whole document — and both labels + /// are `lineLimit(1)`, so scanning or laying out past this is work for text nobody can see. + private static let previewCharacterLimit = 200 + + /// The body with leading blank space skipped, capped at the preview limit. Every read is + /// bounded: `drop(while:)` walks only the leading whitespace and `prefix` stops at the cap, + /// so neither row label is ever proportional to a megabyte-long snippet. + private var previewSource: Substring { + body.drop { $0.isWhitespace }.prefix(Self.previewCharacterLimit) + } + /// The name shown in the list. A snippet saved without a title would otherwise render as a /// blank row the user cannot aim at, so fall back to the first line of its text. public var displayTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } - let firstLine = body.split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" - let trimmedLine = firstLine.trimmingCharacters(in: .whitespaces) - return trimmedLine.isEmpty ? "Untitled" : trimmedLine + let firstLine = previewSource.prefix { $0 != "\n" }.trimmingCharacters(in: .whitespaces) + return firstLine.isEmpty ? "Untitled" : firstLine } /// One-line flattened preview of the text for the list row. public var previewText: String { - body + previewSource .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: .whitespaces) } } @@ -101,8 +119,17 @@ public enum SnippetsKit { /// Unknown braces are left exactly as typed: silently eating `{foo}` would corrupt code and /// templates that legitimately contain braces, and there is no error channel here to report /// it through. + /// Whether `text` holds a token that expansion would actually replace. A bare `contains("{")` + /// test is not the same question: a snippet whose only braces are `{foo}` expands to itself, + /// so anything driven off "does this expand?" — the row's date badge — must ask this instead + /// or it promises a substitution that never happens. + public static func containsPlaceholder(_ text: String) -> Bool { + guard text.contains("{") else { return false } + return placeholders.contains { text.contains($0.token) } + } + public static func expandPlaceholders(in text: String, date: Date = Date(), locale: Locale = .current, timeZone: TimeZone = .current) -> String { - guard text.contains("{") else { return text } + guard containsPlaceholder(text) else { return text } let dateText = string(from: date, dateStyle: .medium, timeStyle: .none, locale: locale, timeZone: timeZone) let timeText = string(from: date, dateStyle: .none, timeStyle: .short, locale: locale, timeZone: timeZone) @@ -166,7 +193,8 @@ public enum SnippetsKit { /// can simply skip a bad write instead of surfacing an error the user cannot act on. public static func encode(_ snippets: [Snippet]) -> Data? { let encoder = JSONEncoder() - // Stable key order keeps the file diffable and its byte-identical-write check meaningful. + // Stable key order so two saves of an unchanged library produce identical bytes, which + // keeps the file diffable and backup-friendly instead of churning on every write. encoder.outputFormatting = [.sortedKeys] return try? encoder.encode(snippets) } diff --git a/Sources/DMonteCore/SnippetsView.swift b/Sources/DMonteCore/SnippetsView.swift index b0ac1e4..e13fdbe 100644 --- a/Sources/DMonteCore/SnippetsView.swift +++ b/Sources/DMonteCore/SnippetsView.swift @@ -66,6 +66,12 @@ public struct SnippetsPopoverView: View { .frostedPanel(cornerRadius: 18) .onAppear { searchFocused = true } .onChange(of: controller.showToken) { _, _ in + // An open editor deliberately survives a reopen, and its overlay covers the search + // field — so refocusing search here would send the user's next keystrokes into a + // control they cannot see, silently filtering the list instead of typing the snippet. + // The panel is only ordered out, never torn down, so leaving focus alone restores the + // editor field the window already had as its first responder. + guard controller.editorSession == nil else { return } searchFocused = true } } @@ -304,7 +310,7 @@ private struct SnippetRow: View { .fill(Color.accentColor.opacity(0.16)) .frame(width: s(28), height: s(28)) .overlay( - Image(systemName: snippet.expandsPlaceholders && snippet.body.contains("{") ? "calendar.badge.clock" : "text.alignleft") + Image(systemName: snippet.pastesADifferentValueEachTime ? "calendar.badge.clock" : "text.alignleft") .font(.system(size: s(12), weight: .semibold)) .foregroundStyle(Color.accentColor) ) diff --git a/Tests/DMonteCoreTests/SnippetsKitTests.swift b/Tests/DMonteCoreTests/SnippetsKitTests.swift index f6fa8a1..5108946 100644 --- a/Tests/DMonteCoreTests/SnippetsKitTests.swift +++ b/Tests/DMonteCoreTests/SnippetsKitTests.swift @@ -134,6 +134,59 @@ final class SnippetsKitTests: XCTestCase { } } + /// The row badge promises "this pastes a fresh date/time". Driving it off a bare `contains("{")` + /// made that promise for any braces at all, so a pure code template was badged as date-bearing + /// while pasting byte-for-byte what was typed. Badge and expansion must answer the same way. + func testTheDateBadgeAgreesWithWhatExpansionActuallyDoes() { + let cases = [ + makeSnippet("Log", "Filed {date}", expands: true), + makeSnippet("Template", "let x = {foo};", expands: true), + makeSnippet("Literal", "Filed {date}", expands: false), + makeSnippet("Plain", "Kind regards", expands: true) + ] + + for snippet in cases { + let pasteChangesTheText = SnippetsKit.pasteText(for: snippet, date: fixedDate) != snippet.body + XCTAssertEqual( + snippet.pastesADifferentValueEachTime, pasteChangesTheText, + "\(snippet.title): badge says \(snippet.pastesADifferentValueEachTime) but pasting changed the text: \(pasteChangesTheText)" + ) + } + } + + func testContainsPlaceholderIgnoresBracesThatAreNotTokens() { + XCTAssertFalse(SnippetsKit.containsPlaceholder("let x = {foo};")) + XCTAssertFalse(SnippetsKit.containsPlaceholder("no braces here")) + XCTAssertTrue(SnippetsKit.containsPlaceholder("Filed {date}")) + XCTAssertTrue(SnippetsKit.containsPlaceholder("At {datetime}")) + } + + // MARK: - Row labels + + func testDisplayTitleFallsBackToTheFirstMeaningfulLineOfTheBody() { + XCTAssertEqual(makeSnippet("", "12 Baker St\nLondon").displayTitle, "12 Baker St") + XCTAssertEqual(makeSnippet(" ", "12 Baker St").displayTitle, "12 Baker St") + // Leading blank lines are skipped rather than reported as an untitled snippet. + XCTAssertEqual(makeSnippet("", "\n\n12 Baker St").displayTitle, "12 Baker St") + XCTAssertEqual(makeSnippet("", " ").displayTitle, "Untitled") + XCTAssertEqual(makeSnippet("", "").displayTitle, "Untitled") + } + + func testPreviewFlattensNewlinesIntoOneLine() { + XCTAssertEqual(makeSnippet("Address", "12 Baker St\nLondon").previewText, "12 Baker St London") + } + + /// Both row labels are single-line and the body is whatever the user pasted in, so a snippet + /// holding a whole document must not hand a megabyte of text to a `lineLimit(1)` label. + func testRowLabelsAreBoundedForAHugeBody() { + let huge = String(repeating: "a", count: 2_000_000) + let snippet = makeSnippet("", huge) + + XCTAssertLessThanOrEqual(snippet.previewText.count, 200) + XCTAssertLessThanOrEqual(snippet.displayTitle.count, 200) + XCTAssertTrue(snippet.previewText.hasPrefix("aaa")) + } + // MARK: - Reordering func testMovedShiftsTheSnippetWithinTheLibrary() { From 19bcc67427f45a6ad6190f247eb18f62f4d646b0 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 21:07:28 +0530 Subject: [PATCH 3/5] Snippets: fix paste, reorder, and dead editing shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate defects, all reported from a real install. **Return dismissed the panel and did nothing.** `paste(_:)` closed the panel first — correct, since focus has to be back in the target app before ⌘V is synthesized — then set `needsAccessibility` when the paste failed. That flag drives a banner on a panel the user can no longer see, so a missing Accessibility grant was indistinguishable from "Return just closes the window". The permission is now checked *before* dismissing: without it the panel stays open, the banner shows, and the system prompt appears. **Reorder pasted instead of reordering.** The row carried `.onTapGesture { onPaste() }` over its whole width, and the reorder and delete buttons are nested inside that row, so tapping Move Up ran the row's paste action — which closes the panel. The tap now covers only the icon, the labels and the gap; the action buttons sit outside it. **⌘C/⌘V/⌘A/⌘Z did nothing in the editor.** No helper in this suite installs a main menu, on the reasonable-sounding theory that an `LSUIElement` app has no menu bar to show one in. But AppKit resolves those shortcuts by matching the event against the main menu's key equivalents before any view sees them, so with no menu they are silently dead in every text field. `HelperMainMenu` installs a never-drawn Edit menu whose items use nil targets, so each one travels the responder chain to the focused text view. That last one is shared infrastructure and this only wires it into Snippets. Every other tool with a text field — Scratchpad, Dev Tools, QR, the Clipboard search field — has the same dead shortcuts and needs the same one-line call. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/HelperMainMenu.swift | 55 +++++++++++++++++++ Sources/DMonteCore/SnippetsController.swift | 9 +++ Sources/DMonteCore/SnippetsView.swift | 52 ++++++++++-------- .../SnippetsAppDelegate.swift | 3 + 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 Sources/DMonteCore/HelperMainMenu.swift diff --git a/Sources/DMonteCore/HelperMainMenu.swift b/Sources/DMonteCore/HelperMainMenu.swift new file mode 100644 index 0000000..0b797b3 --- /dev/null +++ b/Sources/DMonteCore/HelperMainMenu.swift @@ -0,0 +1,55 @@ +import AppKit + +/// Installs the minimal main menu an agent app needs for text editing to work. +/// +/// `LSUIElement` apps have no visible menu bar, which makes it easy to conclude they do not need +/// a main menu at all. They do: AppKit dispatches ⌘C / ⌘V / ⌘X / ⌘A / ⌘Z by matching the event +/// against the **main menu's key equivalents** before anything else sees it. With no main menu +/// there is nothing to match, so those shortcuts are silently dead in every text field the tool +/// presents — the field accepts typing, and copy and paste do nothing at all. +/// +/// The menu is never drawn (there is no menu bar to draw it in); it exists purely so the key +/// equivalents resolve. The items use `nil` targets so each one travels the responder chain and +/// lands on whichever text view is focused. +@MainActor +public enum HelperMainMenu { + + /// Idempotent: a helper that calls this twice, or one that already built its own menu, keeps + /// the menu it has. + public static func installEditMenuIfNeeded() { + guard NSApp.mainMenu == nil else { return } + + let mainMenu = NSMenu() + + // An application menu has to be first — AppKit treats item 0 as the app menu and will not + // look for key equivalents in it. Without this placeholder the Edit menu would occupy that + // slot and its shortcuts would go unmatched, which is the bug this type exists to fix. + let appItem = NSMenuItem() + appItem.submenu = NSMenu() + mainMenu.addItem(appItem) + + let editItem = NSMenuItem() + let editMenu = NSMenu(title: "Edit") + editItem.submenu = editMenu + mainMenu.addItem(editItem) + + // Selectors are built from strings rather than `#selector`: `copy(_:)` and friends are + // declared on several AppKit classes plus `NSObject.copy()`, so the literal is both + // unambiguous and exactly what the responder chain matches on. + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: Selector(("cut:")), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: Selector(("copy:")), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: Selector(("paste:")), keyEquivalent: "v") + editMenu.addItem( + withTitle: "Paste and Match Style", + action: Selector(("pasteAsPlainText:")), + keyEquivalent: "V" + ) + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Select All", action: Selector(("selectAll:")), keyEquivalent: "a") + + NSApp.mainMenu = mainMenu + } +} diff --git a/Sources/DMonteCore/SnippetsController.swift b/Sources/DMonteCore/SnippetsController.swift index b6a2124..c798e6d 100644 --- a/Sources/DMonteCore/SnippetsController.swift +++ b/Sources/DMonteCore/SnippetsController.swift @@ -108,6 +108,15 @@ public final class SnippetsController: ObservableObject { /// Copies the snippet (placeholders resolved) and pastes it into the app the user came from. public func paste(_ snippet: Snippet) { + // Checked *before* dismissing. Closing first and reporting afterwards means the banner is + // set on a panel the user can no longer see, so a missing permission looks exactly like + // "pressing Return just closes the window" — the failure is invisible and unexplained. + guard ClipboardPaste.hasAccessibilityPermission else { + needsAccessibility = true + ClipboardPaste.promptForAccessibilityPermission() + return + } + let text = SnippetsKit.pasteText(for: snippet) let target = pasteTarget // Dismiss first so focus is back in the target app before ⌘V is synthesized. diff --git a/Sources/DMonteCore/SnippetsView.swift b/Sources/DMonteCore/SnippetsView.swift index e13fdbe..fb4f380 100644 --- a/Sources/DMonteCore/SnippetsView.swift +++ b/Sources/DMonteCore/SnippetsView.swift @@ -306,30 +306,38 @@ private struct SnippetRow: View { var body: some View { HStack(spacing: s(10)) { - RoundedRectangle(cornerRadius: s(6), style: .continuous) - .fill(Color.accentColor.opacity(0.16)) - .frame(width: s(28), height: s(28)) - .overlay( - Image(systemName: snippet.pastesADifferentValueEachTime ? "calendar.badge.clock" : "text.alignleft") - .font(.system(size: s(12), weight: .semibold)) - .foregroundStyle(Color.accentColor) - ) + // The paste tap covers the icon, the labels and the gap — but deliberately NOT the + // action buttons. A tap gesture on the whole row swallowed the reorder and delete + // buttons nested inside it, so pressing Move Up pasted the snippet and dismissed the + // panel instead of moving anything. + HStack(spacing: s(10)) { + RoundedRectangle(cornerRadius: s(6), style: .continuous) + .fill(Color.accentColor.opacity(0.16)) + .frame(width: s(28), height: s(28)) + .overlay( + Image(systemName: snippet.pastesADifferentValueEachTime ? "calendar.badge.clock" : "text.alignleft") + .font(.system(size: s(12), weight: .semibold)) + .foregroundStyle(Color.accentColor) + ) - VStack(alignment: .leading, spacing: s(1)) { - Text(snippet.displayTitle) - .font(.system(size: s(12.5), weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) + VStack(alignment: .leading, spacing: s(1)) { + Text(snippet.displayTitle) + .font(.system(size: s(12.5), weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) - Text(snippet.previewText) - .font(.system(size: s(10))) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } + Text(snippet.previewText) + .font(.system(size: s(10))) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } - Spacer(minLength: s(4)) + Spacer(minLength: s(4)) + } + .contentShape(Rectangle()) + .onTapGesture { onPaste() } if isHovered { rowActions @@ -345,9 +353,7 @@ private struct SnippetRow: View { RoundedRectangle(cornerRadius: s(8), style: .continuous) .strokeBorder(Color.accentColor.opacity(isSelected ? 0.5 : 0), lineWidth: 1) ) - .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous)) .onHover { isHovered = $0 } - .onTapGesture { onPaste() } .contextMenu { Button("Paste") { onPaste() } Button("Edit…") { onEdit() } diff --git a/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift b/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift index f9c329c..6b2f2f8 100644 --- a/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift +++ b/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift @@ -37,6 +37,9 @@ final class SnippetsAppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { AppDefaults.registerDefaults() + // The snippet editor is a text field, and without a main menu ⌘C/⌘V/⌘A/⌘Z do not reach it. + HelperMainMenu.installEditMenuIfNeeded() + controller.onRequestClose = { [weak self] in self?.panelHost?.close() } From c74a1a4edced4bfa8b2369b93c75215c9d320cf7 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 01:17:53 +0530 Subject: [PATCH 4/5] Snippets: fix the reorder shortcut, widen the row buttons, name the paste target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **⌘↑ / ⌘↓ moved the selection instead of the snippet.** The guard was `flags == .command`, but arrow keys always report `.numericPad` and `.function` alongside whatever the user held, so that equality is never true for an arrow and both shortcuts fell through to the plain-arrow cases below. Compare only the modifiers a shortcut cares about — command, shift, option, control — and ignore the rest. **The row buttons were an 18pt target with three neighbours.** Missing one landed on the row behind, which pastes and dismisses the panel. Hit area is now 26x24 with an explicit contentShape; the glyph itself only goes 11pt to 12pt, so the row does not get taller. **⏎ looked like it did nothing.** It copies the snippet, dismisses, and synthesizes ⌘V into the app that was frontmost beforehand — but if that app has no focused text field, nothing visibly happens and the panel just closes. The footer now names the destination ("⏎ Paste into Safari"), or says "Copy" when no target was resolved, so the outcome is predictable before the key is pressed rather than mysterious afterwards. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/SnippetsController.swift | 17 ++++++++++++++--- Sources/DMonteCore/SnippetsView.swift | 9 ++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/DMonteCore/SnippetsController.swift b/Sources/DMonteCore/SnippetsController.swift index c798e6d..ce96b8b 100644 --- a/Sources/DMonteCore/SnippetsController.swift +++ b/Sources/DMonteCore/SnippetsController.swift @@ -35,7 +35,14 @@ public final class SnippetsController: ObservableObject { @Published public private(set) var showToken = 0 /// The app to paste into — captured by the delegate before the panel takes focus. - public var pasteTarget: NSRunningApplication? + public var pasteTarget: NSRunningApplication? { + didSet { pasteTargetName = pasteTarget?.localizedName } + } + + /// Name of that app, published so the footer can say where ⏎ will land. Without it a paste + /// into an app with no focused text field is indistinguishable from the panel merely closing, + /// which is exactly how the "Return does nothing" report started. + @Published public private(set) var pasteTargetName: String? /// Set by the delegate; dismisses the panel (returning focus) right before a paste. public var onRequestClose: (() -> Void)? @@ -218,10 +225,14 @@ public final class SnippetsController: ObservableObject { /// Returns true if the key was consumed; false lets it reach the focused control so the user /// can keep typing. public func handleKey(_ event: NSEvent) -> Bool { - // Normalize away Caps Lock so exact modifier checks aren't defeated by it. + // Compare only the modifiers a shortcut is allowed to care about. Arrow keys always + // report `.numericPad` and `.function` alongside whatever the user held, so an exact + // `flags == .command` is never true for ⌘↑ / ⌘↓ — the reorder shortcuts fell through to + // the plain-arrow cases below and moved the selection instead of the snippet. + let meaningfulModifiers: NSEvent.ModifierFlags = [.command, .shift, .option, .control] let flags = event.modifierFlags .intersection(.deviceIndependentFlagsMask) - .subtracting(.capsLock) + .intersection(meaningfulModifiers) let isCommandOnly = flags == .command // While the editor is open every key belongs to it — arrows move the caret, Return diff --git a/Sources/DMonteCore/SnippetsView.swift b/Sources/DMonteCore/SnippetsView.swift index fb4f380..b93f9f5 100644 --- a/Sources/DMonteCore/SnippetsView.swift +++ b/Sources/DMonteCore/SnippetsView.swift @@ -251,7 +251,7 @@ public struct SnippetsPopoverView: View { private var footer: some View { HStack(spacing: s(10)) { - footerHint("⏎", "Paste") + footerHint("⏎", controller.pasteTargetName.map { "Paste into \($0)" } ?? "Copy") footerHint("⌘N", "New") footerHint("⌘E", "Edit") Spacer() @@ -378,9 +378,12 @@ private struct SnippetRow: View { private func rowButton(systemImage: String, help: String, isEnabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) - .font(.system(size: s(11))) + .font(.system(size: s(12))) .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.35)) - .frame(width: s(18), height: s(18)) + // Hit area, not glyph size: an 18pt target between three neighbours is easy to + // miss, and missing one used to land on the row underneath and paste. + .frame(width: s(26), height: s(24)) + .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!isEnabled) From 50e7b83a240db7bbaffa945418de13dc58826ebe Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 02:10:16 +0530 Subject: [PATCH 5/5] =?UTF-8?q?Snippets:=20reorder=20with=20=E2=8C=A5?= =?UTF-8?q?=E2=8C=98=E2=86=91/=E2=8C=A5=E2=8C=98=E2=86=93=20to=20clear=20t?= =?UTF-8?q?he=20Mission=20Control=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare ⌘↑ / ⌘↓ collide with Mission Control and space-switching shortcuts on many setups, and a system-reserved shortcut is consumed before the app's local key monitor ever sees the event — so the reorder key went to Mission Control and the tool could not intercept it. Moved reorder to ⌥⌘↑ / ⌥⌘↓, the common move-item convention, which no default system shortcut claims. Footer hint and button tooltips updated to match. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/SnippetsController.swift | 8 ++++++-- Sources/DMonteCore/SnippetsView.swift | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/DMonteCore/SnippetsController.swift b/Sources/DMonteCore/SnippetsController.swift index ce96b8b..51e44be 100644 --- a/Sources/DMonteCore/SnippetsController.swift +++ b/Sources/DMonteCore/SnippetsController.swift @@ -234,6 +234,10 @@ public final class SnippetsController: ObservableObject { .intersection(.deviceIndependentFlagsMask) .intersection(meaningfulModifiers) let isCommandOnly = flags == .command + // ⌥⌘ for reorder rather than plain ⌘: bare ⌘↑ / ⌘↓ collide with Mission Control and + // space-switching shortcuts on many setups, and a system shortcut is consumed before this + // local monitor ever sees the event, so the app cannot win that race. Option steps aside. + let isCommandOption = flags == [.command, .option] // While the editor is open every key belongs to it — arrows move the caret, Return // inserts a newline — except Escape, which cancels the session. @@ -246,10 +250,10 @@ public final class SnippetsController: ObservableObject { } switch Int(event.keyCode) { - case 126 where isCommandOnly: // ⌘↑ move the selected snippet up + case 126 where isCommandOption: // ⌥⌘↑ move the selected snippet up if let selectedID { move(selectedID, by: -1) } return true - case 125 where isCommandOnly: // ⌘↓ move the selected snippet down + case 125 where isCommandOption: // ⌥⌘↓ move the selected snippet down if let selectedID { move(selectedID, by: 1) } return true case 126: // up arrow diff --git a/Sources/DMonteCore/SnippetsView.swift b/Sources/DMonteCore/SnippetsView.swift index b93f9f5..ae98b1e 100644 --- a/Sources/DMonteCore/SnippetsView.swift +++ b/Sources/DMonteCore/SnippetsView.swift @@ -255,7 +255,7 @@ public struct SnippetsPopoverView: View { footerHint("⌘N", "New") footerHint("⌘E", "Edit") Spacer() - footerHint("⌘↑↓", "Reorder") + footerHint("⌥⌘↑↓", "Reorder") } .padding(.horizontal, s(14)) .padding(.top, s(6)) @@ -368,8 +368,8 @@ private struct SnippetRow: View { private var rowActions: some View { HStack(spacing: s(2)) { - rowButton(systemImage: "chevron.up", help: "Move up", isEnabled: canMoveUp, action: onMoveUp) - rowButton(systemImage: "chevron.down", help: "Move down", isEnabled: canMoveDown, action: onMoveDown) + rowButton(systemImage: "chevron.up", help: "Move up (⌥⌘↑)", isEnabled: canMoveUp, action: onMoveUp) + rowButton(systemImage: "chevron.down", help: "Move down (⌥⌘↓)", isEnabled: canMoveDown, action: onMoveDown) rowButton(systemImage: "pencil", help: "Edit", isEnabled: true, action: onEdit) rowButton(systemImage: "trash", help: "Delete", isEnabled: true, action: onDelete) }