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) + } +}