diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63396d4..f61110f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,14 @@ adheres to [Semantic Versioning](https://semver.org) and the
offers one, and says so plainly when a device exposes no mute control instead
of offering a button that does nothing. Setting a device's mute switch is not
audio capture, so it needs no microphone permission.
+- **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 8d0d447..0992121 100644
--- a/Package.swift
+++ b/Package.swift
@@ -99,6 +99,10 @@ let package = Package(
.executable(
name: "DMonteMicControl",
targets: ["DMonteMicControl"]
+ ),
+ .executable(
+ name: "DMonteSnippets",
+ targets: ["DMonteSnippets"]
)
],
dependencies: [
@@ -264,6 +268,13 @@ let package = Package(
],
path: "Sources/DMonteMicControlApp"
),
+ .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 f7d46d2..f8a98bb 100644
--- a/README.md
+++ b/README.md
@@ -43,13 +43,15 @@ 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, JWT, URL, hashing, UUID, timestamp, and case utilities |
+| **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 2446c72..7718edf 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -37,6 +37,7 @@ HELPERS=(
"DMonteFocusTimer|DMonte Focus Timer.app|FocusTimerInfo.plist"
"DMonteWindowManager|DMonte Window Manager.app|WindowManagerInfo.plist"
"DMonteMicControl|DMonte Mic Control.app|MicControlInfo.plist"
+ "DMonteSnippets|DMonte Snippets.app|SnippetsInfo.plist"
)
stamp_version() {
diff --git a/Sources/DMonteCore/ClipboardPaste.swift b/Sources/DMonteCore/ClipboardPaste.swift
index 1d1b8cd..57d5d47 100644
--- a/Sources/DMonteCore/ClipboardPaste.swift
+++ b/Sources/DMonteCore/ClipboardPaste.swift
@@ -118,6 +118,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/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
new file mode 100644
index 0000000..51e44be
--- /dev/null
+++ b/Sources/DMonteCore/SnippetsController.swift
@@ -0,0 +1,288 @@
+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? {
+ 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)?
+
+ 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) {
+ // 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.
+ 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 {
+ // 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)
+ .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.
+ if editorSession != nil {
+ if event.keyCode == 53 {
+ cancelEditing()
+ return true
+ }
+ return false
+ }
+
+ switch Int(event.keyCode) {
+ case 126 where isCommandOption: // ⌥⌘↑ move the selected snippet up
+ if let selectedID { move(selectedID, by: -1) }
+ return true
+ case 125 where isCommandOption: // ⌥⌘↓ 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..b16c657
--- /dev/null
+++ b/Sources/DMonteCore/SnippetsKit.swift
@@ -0,0 +1,211 @@
+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()
+ }
+
+ /// 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 = 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 {
+ previewSource
+ .replacingOccurrences(of: "\n", with: " ")
+ .trimmingCharacters(in: .whitespaces)
+ }
+}
+
+/// 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.
+ /// 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 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)
+ 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 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)
+ }
+
+ /// 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..ae98b1e
--- /dev/null
+++ b/Sources/DMonteCore/SnippetsView.swift
@@ -0,0 +1,568 @@
+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
+ // 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
+ }
+ }
+
+ // 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("⏎", controller.pasteTargetName.map { "Paste into \($0)" } ?? "Copy")
+ 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)) {
+ // 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)
+
+ Text(snippet.previewText)
+ .font(.system(size: s(10)))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer(minLength: s(4))
+ }
+ .contentShape(Rectangle())
+ .onTapGesture { onPaste() }
+
+ 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)
+ )
+ .onHover { isHovered = $0 }
+ .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(12)))
+ .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.35))
+ // 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)
+ .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 ddc126f..d03c5e4 100644
--- a/Sources/DMonteCore/ToolboxCatalog.swift
+++ b/Sources/DMonteCore/ToolboxCatalog.swift
@@ -65,7 +65,8 @@ public enum ToolboxCatalog {
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: "micControl", title: "Mic Control", iconName: "mic.slash.fill", tint: .orange, bundleID: prefix + "miccontrol", appName: "DMonte Mic Control.app", executableName: "DMonteMicControl", arguments: ["--open"])
+ ToolboxTool(id: "micControl", title: "Mic Control", iconName: "mic.slash.fill", tint: .orange, bundleID: prefix + "miccontrol", appName: "DMonte Mic Control.app", executableName: "DMonteMicControl", 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..6b2f2f8
--- /dev/null
+++ b/Sources/DMonteSnippetsApp/SnippetsAppDelegate.swift
@@ -0,0 +1,136 @@
+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()
+
+ // 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()
+ }
+
+ 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..5108946
--- /dev/null
+++ b/Tests/DMonteCoreTests/SnippetsKitTests.swift
@@ -0,0 +1,338 @@
+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"
+ )
+ }
+ }
+
+ /// 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() {
+ 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)
+ }
+}