diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4e9936..52a134c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org) and the
## [Unreleased]
+### Added
+- **File Shelf**, a new menu bar tool: a drag‑and‑drop staging area for moving
+ files between apps and Spaces without keeping a Finder window open. Drop
+ files onto the menu bar icon or into the popover to park them, then drag them
+ straight back out into any app — the drag hands over the real file, so the
+ receiving app gets the original rather than a copy. Each row also offers
+ Reveal in Finder, Copy Path and Remove, and the menu bar icon carries a count
+ of what you are currently carrying.
+
+ Shelving never copies bytes: an entry is a security‑scoped bookmark to the
+ original file, so parking a 40 GB video costs the same as parking a note, the
+ shelf survives a relaunch, and an entry keeps working after the file is
+ renamed or moved. A file that has been deleted, or that lives on a disk you
+ have unplugged, stays on the shelf and is shown as unavailable rather than
+ quietly disappearing — the shelf's job is to tell you where the thing you
+ parked went, not to pretend you never parked it.
+
## [0.14.0] — 2026-07-21
### Added
diff --git a/Package.swift b/Package.swift
index e4e8e9a..13eeb99 100644
--- a/Package.swift
+++ b/Package.swift
@@ -115,6 +115,10 @@ let package = Package(
.executable(
name: "DMonteNetworkInfo",
targets: ["DMonteNetworkInfo"]
+ ),
+ .executable(
+ name: "DMonteFileShelf",
+ targets: ["DMonteFileShelf"]
)
],
dependencies: [
@@ -308,6 +312,13 @@ let package = Package(
],
path: "Sources/DMonteNetworkInfoApp"
),
+ .executableTarget(
+ name: "DMonteFileShelf",
+ dependencies: [
+ "DMonteCore"
+ ],
+ path: "Sources/DMonteFileShelfApp"
+ ),
.testTarget(
name: "DMonteCoreTests",
dependencies: ["DMonteCore"],
diff --git a/Packaging/FileShelfInfo.plist b/Packaging/FileShelfInfo.plist
new file mode 100644
index 0000000..99b1fe9
--- /dev/null
+++ b/Packaging/FileShelfInfo.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ DMonteFileShelf
+ CFBundleIdentifier
+ com.havokentity.mactools.fileshelf
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleDisplayName
+ DMonte File Shelf
+ CFBundleName
+ DMonte File Shelf
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.14.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 14.0
+ LSMultipleInstancesProhibited
+
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2026 Yahushad Monte
+
+
diff --git a/README.md b/README.md
index 524f959..fb25414 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org);
| **Snippets** | A searchable library of reusable text — click one to paste it into the app you came from |
| **Scratchpad** | Always‑there plain‑text notepad with named notes, autosave, and a live word count |
| **Network Info** | Interface, local IPv4/IPv6, subnet, router, and DNS at a glance — click any value to copy; public IP only on request |
+| **File Shelf** | Drag files onto the menu bar icon to park them, then drag them back out into any app — nothing is copied, and entries follow files that get renamed or moved |
### Permissions
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index 107b313..fdf72ff 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -41,6 +41,7 @@ HELPERS=(
"DMonteDriveEjector|DMonte Drive Ejector.app|DriveEjectorInfo.plist"
"DMonteScratchpad|DMonte Scratchpad.app|ScratchpadInfo.plist"
"DMonteNetworkInfo|DMonte Network Info.app|NetworkInfoInfo.plist"
+ "DMonteFileShelf|DMonte File Shelf.app|FileShelfInfo.plist"
)
stamp_version() {
diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift
index 8a9a1ec..5b05033 100644
--- a/Sources/DMonteCore/AppPreferences.swift
+++ b/Sources/DMonteCore/AppPreferences.swift
@@ -130,7 +130,8 @@ public enum AppDefaults {
DefaultsKey.focusTimerLongBreakInterval: 4,
DefaultsKey.driveEjectorConfirmsEjectAll: true,
DefaultsKey.scratchpadFontSize: 13,
- DefaultsKey.networkInfoFetchesPublicIPAutomatically: false
+ DefaultsKey.networkInfoFetchesPublicIPAutomatically: false,
+ DefaultsKey.fileShelfConfirmsClearAll: true
])
}
}
diff --git a/Sources/DMonteCore/FileShelfKit.swift b/Sources/DMonteCore/FileShelfKit.swift
new file mode 100644
index 0000000..c5ceefb
--- /dev/null
+++ b/Sources/DMonteCore/FileShelfKit.swift
@@ -0,0 +1,394 @@
+import Foundation
+
+/// Whether a shelved entry can still be handed to another app.
+///
+/// An entry is never silently dropped when it stops being usable. The shelf is a place people
+/// park things on their way somewhere else, so "the file I put here is gone" has to be visible —
+/// a row that quietly disappeared would read as the shelf having lost it.
+public enum ShelfAvailability: String, Codable, Sendable, Equatable {
+ /// The bookmark resolved and something is at the other end.
+ case available
+
+ /// The bookmark is real, but the file it names cannot be reached right now: deleted, or on a
+ /// volume that is not mounted. Plugging the disk back in makes this entry work again, which is
+ /// exactly why it is kept rather than pruned.
+ case missing
+
+ /// The stored bytes are not usable bookmark data at all — the file could never be bookmarked,
+ /// or the record was corrupted. Recoverable only by shelving the file again, but still worth
+ /// showing so the user can see what they have lost.
+ case unresolvable
+}
+
+/// The persisted form of one shelved file.
+///
+/// The bookmark — not the path — is the identity of record. A shelf whose entries were paths
+/// would break the moment someone renamed a file in Finder or moved it between folders, which is
+/// precisely the kind of tidying people do while a file is parked here on its way somewhere else.
+/// `displayName` and `lastKnownPath` exist only to label a row whose bookmark has stopped
+/// resolving; while the bookmark works, the live URL wins.
+public struct ShelvedFile: Codable, Sendable, Identifiable, Equatable {
+ public let id: UUID
+
+ /// Security-scoped bookmark data produced by `FileShelfKit.bookmarkData(for:)`. Refreshed in
+ /// place whenever resolution reports it as stale, so a file that keeps moving does not slowly
+ /// accumulate resolution work on every launch.
+ public var bookmark: Data
+
+ /// The file's name when it was last successfully resolved.
+ public var displayName: String
+
+ /// The file's path when it was last successfully resolved. Shown greyed-out on an entry that
+ /// no longer resolves, because "which file was that?" is the only question left to answer.
+ public var lastKnownPath: String
+
+ /// When the file was last put on the shelf. Re-dropping something already shelved refreshes
+ /// this rather than creating a second row.
+ public var shelved: Date
+
+ public init(
+ id: UUID = UUID(),
+ bookmark: Data,
+ displayName: String,
+ lastKnownPath: String,
+ shelved: Date = Date()
+ ) {
+ self.id = id
+ self.bookmark = bookmark
+ self.displayName = displayName
+ self.lastKnownPath = lastKnownPath
+ self.shelved = shelved
+ }
+}
+
+/// A shelved file after its bookmark has been resolved: the record, where the file actually is
+/// right now, and whether it can be handed to another app.
+///
+/// This is the type the list operations work on, and it carries `identity` so de-duplication can
+/// compare *live* locations. Storing the identity on the record instead would defeat the point:
+/// a device/inode pair is not stable across a restart, so it has to be recomputed from whatever
+/// the bookmark resolves to today.
+public struct ShelfItem: Sendable, Identifiable, Equatable {
+ public var file: ShelvedFile
+
+ /// Where the file is right now; `nil` when the bookmark no longer resolves.
+ public var url: URL?
+
+ public var availability: ShelfAvailability
+
+ /// `FileShelfKit.identity(for:)` of `url`. `nil` when nothing resolved, or when the resolved
+ /// location has no file behind it — an entry with no identity can never collide with anything.
+ public var identity: String?
+
+ public var id: UUID { file.id }
+
+ public init(file: ShelvedFile, url: URL?, availability: ShelfAvailability, identity: String?) {
+ self.file = file
+ self.url = url
+ self.availability = availability
+ self.identity = identity
+ }
+
+ /// The live name when the file is still reachable, otherwise the one recorded at shelving time.
+ public var displayName: String {
+ url?.lastPathComponent ?? file.displayName
+ }
+
+ /// The live path when the file is still reachable, otherwise the one recorded at shelving time.
+ public var displayPath: String {
+ url?.path ?? file.lastKnownPath
+ }
+
+ public var isAvailable: Bool {
+ availability == .available
+ }
+}
+
+/// A bookmark that resolved, plus whether the system wants it rewritten.
+public struct ResolvedShelfBookmark: Sendable, Equatable {
+ public let url: URL
+
+ /// `true` when macOS decided the bookmark's cached information has drifted far enough that it
+ /// should be re-created. Ignoring this is what makes a bookmark eventually stop resolving.
+ public let isStale: Bool
+
+ public init(url: URL, isStale: Bool) {
+ self.url = url
+ self.isStale = isStale
+ }
+}
+
+/// Pure, UI-free logic behind DMonte File Shelf: bookmark creation and resolution, file identity,
+/// the ordering/de-duplication rules for the shelf list, and the on-disk format.
+///
+/// Everything here is either a pure function over values or an explicitly file-touching helper
+/// that takes the URL it should touch, so the whole layer is exercisable from tests against a
+/// temporary directory without a menu bar, a window, or the user's real shelf.
+public enum FileShelfKit {
+ /// How many files the shelf holds before the oldest falls off the bottom.
+ ///
+ /// A staging area is not an archive. Without a cap a shelf someone forgot about keeps every
+ /// bookmark they ever dropped and re-resolves all of them on every launch; with one, the list
+ /// stays scannable and the oldest thing — the one least likely to still be wanted — is what
+ /// makes room.
+ public static let maximumCount = 50
+
+ /// Above this the badge stops counting and just says "there are a lot", because a three-digit
+ /// number in the menu bar is wider than the icon it is annotating.
+ public static let maximumBadgeCount = 99
+
+ // MARK: - Status badge
+
+ /// The count drawn next to the menu-bar icon. `nil` when the shelf is empty, so an unused
+ /// shelf is just an icon rather than a permanent "0" taking up menu-bar width.
+ public static func badgeTitle(for count: Int) -> String? {
+ guard count > 0 else { return nil }
+ guard count <= maximumBadgeCount else { return "\(maximumBadgeCount)+" }
+ return "\(count)"
+ }
+
+ // MARK: - File identity
+
+ /// A key that identifies *the file itself* rather than the name it currently goes by.
+ ///
+ /// Device number plus inode: the same pair for a file reached through a symlink, through
+ /// `/tmp` versus `/private/tmp`, or after a rename — which is what de-duplication has to
+ /// survive, since the shelf deliberately follows files as they move. Falls back to the
+ /// standardized path when the file cannot be stat'ed at all (it is gone, or on a volume that
+ /// has been unplugged), because a path is the only identity left at that point.
+ ///
+ /// Deliberately not persisted: inode numbers are reused after a file is deleted, so a stored
+ /// identity could later match a completely unrelated file.
+ public static func identity(for url: URL) -> String {
+ let standardized = url.standardizedFileURL.resolvingSymlinksInPath()
+
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: standardized.path),
+ let device = attributes[.systemNumber] as? NSNumber,
+ let inode = attributes[.systemFileNumber] as? NSNumber else {
+ return standardized.path
+ }
+
+ return "\(device.uint64Value):\(inode.uint64Value)"
+ }
+
+ // MARK: - Bookmarks
+
+ /// Bookmark data for `url`, or `nil` when the file cannot be bookmarked at all.
+ ///
+ /// The security-scoped variant is tried first because it is what keeps working if this tool is
+ /// ever sandboxed. Outside the App Sandbox the scope buys nothing and the scoped variant can
+ /// be refused outright, so a plain bookmark is the fallback: it still survives a relaunch and
+ /// still follows a rename, which is the whole reason the shelf stores bookmarks and not paths.
+ public static func bookmarkData(for url: URL) -> Data? {
+ if let scoped = try? url.bookmarkData(
+ options: [.withSecurityScope],
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ ) {
+ return scoped
+ }
+
+ return try? url.bookmarkData(
+ options: [],
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ )
+ }
+
+ /// Resolves bookmark data back to a location, or `nil` when it no longer resolves.
+ ///
+ /// Mirrors `bookmarkData(for:)`: a plain bookmark throws if resolved *with* the security
+ /// scope, so both shapes are attempted. `.withoutUI` and `.withoutMounting` matter more than
+ /// they look — without them, resolving an entry that lives on an unplugged disk pops a system
+ /// "please insert the disk" panel from a background menu-bar app during a routine refresh.
+ public static func resolve(_ bookmark: Data) -> ResolvedShelfBookmark? {
+ if let scoped = resolve(bookmark, options: [.withSecurityScope, .withoutUI, .withoutMounting]) {
+ return scoped
+ }
+
+ return resolve(bookmark, options: [.withoutUI, .withoutMounting])
+ }
+
+ private static func resolve(_ bookmark: Data, options: URL.BookmarkResolutionOptions) -> ResolvedShelfBookmark? {
+ var isStale = false
+ guard let url = try? URL(
+ resolvingBookmarkData: bookmark,
+ options: options,
+ relativeTo: nil,
+ bookmarkDataIsStale: &isStale
+ ) else {
+ return nil
+ }
+
+ return ResolvedShelfBookmark(url: url, isStale: isStale)
+ }
+
+ /// Turns a dropped URL into a shelf item.
+ ///
+ /// A file that cannot be bookmarked at all is still shelved, with empty bookmark data: it is
+ /// usable for the rest of this session (the URL is right there) and will read as unresolvable
+ /// after a relaunch. Refusing the drop outright would be worse — the user would see a drag
+ /// that silently did nothing — and quietly substituting the recorded path on the next launch
+ /// would be worse still, because a path can be reused by an unrelated file and this tool's
+ /// entire job is to hand over *the* file.
+ public static func makeItem(for url: URL, shelved: Date = Date()) -> ShelfItem {
+ let standardized = url.standardizedFileURL
+ let file = ShelvedFile(
+ bookmark: bookmarkData(for: standardized) ?? Data(),
+ displayName: standardized.lastPathComponent,
+ lastKnownPath: standardized.path,
+ shelved: shelved
+ )
+
+ let exists = FileManager.default.fileExists(atPath: standardized.path)
+ return ShelfItem(
+ file: file,
+ url: standardized,
+ availability: exists ? .available : .missing,
+ identity: exists ? identity(for: standardized) : nil
+ )
+ }
+
+ /// Whether `bookmark` still describes a file, answered from the bookmark's own cached resource
+ /// values rather than by resolving it.
+ ///
+ /// This is the only way to tell "a real bookmark I cannot follow right now" apart from "these
+ /// bytes are not bookmark data", because resolution fails identically for both. Bookmark data
+ /// carries a cached copy of its target's name and path that can be read while the file is
+ /// deleted or its volume is unmounted; junk and empty data carry nothing.
+ private static func describesAFile(_ bookmark: Data) -> Bool {
+ guard !bookmark.isEmpty else { return false }
+ return URL.resourceValues(forKeys: [.pathKey], fromBookmarkData: bookmark) != nil
+ }
+
+ /// Re-resolves a stored record into a live item.
+ ///
+ /// A stale-but-resolvable bookmark is rewritten in the returned record, so the caller only has
+ /// to persist what it gets back. The distinction the two unavailable states draw is "the file
+ /// is not reachable right now" versus "this data is junk", and only the first one comes back on
+ /// its own.
+ public static func resolveItem(_ file: ShelvedFile) -> ShelfItem {
+ guard let resolved = resolve(file.bookmark) else {
+ // Failing to resolve does not by itself mean the bookmark is junk. A deleted file, or a
+ // file on a volume that is not mounted, makes resolution fail outright — `.withoutMounting`
+ // sees to the second case — so classifying every failure as `.unresolvable` would tell
+ // someone whose external disk is merely unplugged that the entry has to be shelved
+ // again, and they would remove a row that was about to start working by itself.
+ let availability: ShelfAvailability = describesAFile(file.bookmark) ? .missing : .unresolvable
+ return ShelfItem(file: file, url: nil, availability: availability, identity: nil)
+ }
+
+ var updated = file
+ if resolved.isStale, let refreshed = bookmarkData(for: resolved.url) {
+ updated.bookmark = refreshed
+ }
+
+ guard FileManager.default.fileExists(atPath: resolved.url.path) else {
+ return ShelfItem(file: updated, url: resolved.url, availability: .missing, identity: nil)
+ }
+
+ updated.displayName = resolved.url.lastPathComponent
+ updated.lastKnownPath = resolved.url.path
+
+ return ShelfItem(
+ file: updated,
+ url: resolved.url,
+ availability: .available,
+ identity: identity(for: resolved.url)
+ )
+ }
+
+ // MARK: - List operations
+
+ /// Puts `item` at the top of `items`, collapsing a file that is already shelved onto its
+ /// existing row.
+ ///
+ /// Newest-first because a shelf is read from the top: the thing just dropped is the thing most
+ /// likely to be dragged out next. Re-dropping something already on the shelf moves that row
+ /// back to the top rather than doing nothing at all — a drop that appeared to have no effect
+ /// reads as a drop that was not registered — but keeps the original row's `id` so the list
+ /// does not lose scroll position or a hover state to a fresh identity.
+ ///
+ /// The cap is applied from the bottom, so an add can never fail: the oldest entry makes room.
+ public static func adding(_ item: ShelfItem, to items: [ShelfItem]) -> [ShelfItem] {
+ var newItem = item
+ var remaining = items
+
+ // Only a real identity can collide. Two entries that both failed to resolve are not
+ // "the same file" just because neither of them could be found.
+ if let identity = item.identity,
+ let existingIndex = remaining.firstIndex(where: { $0.identity == identity }) {
+ newItem.file = ShelvedFile(
+ id: remaining[existingIndex].id,
+ bookmark: item.file.bookmark,
+ displayName: item.file.displayName,
+ lastKnownPath: item.file.lastKnownPath,
+ shelved: item.file.shelved
+ )
+ remaining.remove(at: existingIndex)
+ }
+
+ remaining.removeAll { $0.id == newItem.id }
+ remaining.insert(newItem, at: 0)
+ return Array(remaining.prefix(maximumCount))
+ }
+
+ /// Adds several dropped files at once, preserving the order they were dropped in: the first
+ /// URL of a multi-file drag ends up at the top, matching how the drag was assembled.
+ public static func adding(_ newItems: [ShelfItem], to items: [ShelfItem]) -> [ShelfItem] {
+ newItems.reversed().reduce(items) { partial, item in adding(item, to: partial) }
+ }
+
+ public static func removing(id: UUID, from items: [ShelfItem]) -> [ShelfItem] {
+ items.filter { $0.id != id }
+ }
+
+ // MARK: - Storage
+
+ /// Creates `directory` if needed, restricted to the current user.
+ ///
+ /// The shelf file is a list of paths to whatever someone was moving around — a contract, a
+ /// medical scan, an unreleased build — so 0700 is the right default. Returns false unless a
+ /// real directory is in place afterwards.
+ @discardableResult
+ public static func prepareDirectory(_ directory: URL) -> Bool {
+ let ownerOnly: [FileAttributeKey: Any] = [.posixPermissions: 0o700]
+ try? FileManager.default.createDirectory(
+ at: directory,
+ withIntermediateDirectories: true,
+ attributes: ownerOnly
+ )
+ // Re-asserted separately: createDirectory only applies its attributes to directories it
+ // actually creates, so a directory from an earlier build would keep its old mode.
+ try? FileManager.default.setAttributes(ownerOnly, ofItemAtPath: directory.path)
+
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory) else {
+ return false
+ }
+ return isDirectory.boolValue
+ }
+
+ /// Reads the shelf from disk. A missing or unreadable file is an empty shelf, not an error —
+ /// there is nothing to recover and nothing the user could do about it.
+ public static func load(from url: URL) -> [ShelvedFile] {
+ guard let data = try? Data(contentsOf: url) else { return [] }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return (try? decoder.decode([ShelvedFile].self, from: data)) ?? []
+ }
+
+ /// Writes the shelf atomically, so an interrupted save leaves the previous shelf intact rather
+ /// than a half-written file that would read back as empty.
+ @discardableResult
+ public static func write(_ files: [ShelvedFile], to url: URL) -> Bool {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ guard let data = try? encoder.encode(files) else { return false }
+ guard (try? data.write(to: url, options: .atomic)) != nil else { return false }
+
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
+ return true
+ }
+}
diff --git a/Sources/DMonteCore/FileShelfSizing.swift b/Sources/DMonteCore/FileShelfSizing.swift
new file mode 100644
index 0000000..6657d82
--- /dev/null
+++ b/Sources/DMonteCore/FileShelfSizing.swift
@@ -0,0 +1,35 @@
+import AppKit
+
+public enum FileShelfSizing {
+ public static func preferredSize() -> NSSize {
+ let scale = currentScale
+ // A touch wider than the toggle-style tools: every row carries a file name over its
+ // folder path, and at 320pt both of them truncate before they have said anything useful.
+ return NSSize(width: (340 * scale).rounded(), height: (470 * scale).rounded())
+ }
+
+ /// The settings overlay, which is centred *over* the panel and therefore must never be wider
+ /// than it. A hard-coded size looks right only at `currentScale == 1`; on a Mac whose menu bar
+ /// is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally on
+ /// both sides and its first and last characters are clipped.
+ ///
+ /// Capped at the panel rather than scaled with it. The sheet's contents are laid out at this
+ /// size with unscaled padding, so shrinking it further than the panel demands would squeeze
+ /// them for no reason — and on the tools whose sheet is already narrower than their panel,
+ /// scaling would inset it noticeably while fixing nothing.
+ public static func settingsSize() -> NSSize {
+ let panel = preferredSize()
+ // Leave room for the 18pt padding PreferencesOverlay adds around the sheet on every
+ // side: a sheet sized to the full panel becomes panel+36 once padded and spills
+ // the panel, dragging the content behind it off both edges.
+ let overlayChrome: CGFloat = 36
+ return NSSize(width: min(300, panel.width - overlayChrome), height: min(300, panel.height - overlayChrome))
+ }
+
+ 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/FileShelfStore.swift b/Sources/DMonteCore/FileShelfStore.swift
new file mode 100644
index 0000000..6a5a0ed
--- /dev/null
+++ b/Sources/DMonteCore/FileShelfStore.swift
@@ -0,0 +1,263 @@
+import AppKit
+import Foundation
+
+public extension DefaultsKey {
+ /// Whether Clear Shelf asks for a second press before emptying the shelf. Clearing is the one
+ /// action here the user cannot undo — the files are untouched, but the collection they were
+ /// deliberately gathering is gone — so the integrator registers a default of `true`.
+ static let fileShelfConfirmsClearAll = "tool.fileShelf.confirmsClearAll"
+}
+
+/// Owns the shelf: the ordered list of shelved files, their resolution against the file system,
+/// and the JSON file in Application Support that carries them across a relaunch.
+///
+/// Nothing here copies bytes. Shelving a file records a bookmark to it and nothing else, so
+/// parking a 40 GB video costs the same as parking a text file, and dragging it back out hands
+/// the receiving app the original rather than a duplicate.
+///
+/// All the interesting rules live in `FileShelfKit` as pure functions over values; this type is
+/// the main-actor shell that owns the state, the security-scoped access, and the file.
+@MainActor
+public final class FileShelfStore: ObservableObject {
+ /// The shelf, newest first.
+ @Published public private(set) var items: [ShelfItem] = []
+
+ /// `true` once Clear Shelf has been requested and is waiting for the confirming press.
+ @Published public private(set) var isConfirmingClearAll = false
+
+ /// Persisted preference: require a second press for Clear Shelf.
+ @Published public private(set) var confirmsClearAll: Bool
+
+ /// The most recent thing worth saying out loud — a drop that added nothing, a reveal that
+ /// found nothing. `nil` most of the time; the popover only shows the line when there is one.
+ @Published public private(set) var statusMessage: String?
+
+ private let shelfFileURL: URL
+ private let defaults: UserDefaults
+
+ /// URLs this process called `startAccessingSecurityScopedResource()` on, keyed by the entry
+ /// that owns the claim. Security scopes are reference-counted per process, so every start has
+ /// to be balanced exactly once — hence a dictionary rather than a bare set, which would lose
+ /// track of the second claim on the same URL.
+ private var accessedURLs: [UUID: URL] = [:]
+
+ 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("DMonteFileShelf", isDirectory: true),
+ defaults: AppDefaults.shared
+ )
+ }
+
+ /// Directory- and defaults-injecting initializer. Internal so tests can point the store at a
+ /// temporary directory and its own preferences suite, exercising the real load/save path
+ /// without touching the user's own shelf or settings.
+ init(directory: URL, defaults: UserDefaults) {
+ self.defaults = defaults
+ confirmsClearAll = defaults.bool(forKey: DefaultsKey.fileShelfConfirmsClearAll)
+ shelfFileURL = directory.appendingPathComponent("shelf.json")
+ FileShelfKit.prepareDirectory(directory)
+
+ // Resolve at startup rather than lazily per row: the count in the menu bar has to be
+ // truthful the moment the icon appears, and that means knowing what is still there.
+ adopt(FileShelfKit.load(from: shelfFileURL).map { FileShelfKit.resolveItem($0) }, persist: false)
+ }
+
+ // MARK: - Shelving
+
+ /// Shelves each URL, newest first, and returns how many rows the shelf actually gained.
+ ///
+ /// A re-drop of something already shelved counts as zero: it moved a row rather than adding
+ /// one, and reporting "1 added" when the list is the same length reads as a bug.
+ @discardableResult
+ public func shelve(urls: [URL]) -> Int {
+ let candidates = urls.map { FileShelfKit.makeItem(for: $0) }
+ guard !candidates.isEmpty else { return 0 }
+
+ // Re-resolve the whole shelf before comparing anything against it, because the identities
+ // already on `items` were computed the last time the shelf was resolved — startup, or the
+ // last popover open — and a device/inode pair does not survive a safe-save. Every app that
+ // writes atomically (TextEdit, Preview, Xcode, anything built on NSDocument or
+ // `FileManager.replaceItemAt`) swaps in a fresh inode, so the cached identity stops
+ // describing the file the bookmark still points at. A drop straight onto the menu-bar icon
+ // never opens the popover, so this is the only chance to notice; without it, re-dropping a
+ // file that has been edited since fails to match and mints a second, permanent row that
+ // nothing ever collapses.
+ let current = resolvedItems()
+
+ // Counted from identities rather than from the length of the list before and after.
+ // A shelf already at `maximumCount` stays exactly that long when something is added, so
+ // the length would report every drop onto a full shelf as a duplicate.
+ var seenIdentities = Set(current.compactMap(\.identity))
+ let added = candidates.reduce(into: 0) { total, candidate in
+ guard let identity = candidate.identity else {
+ // Nothing to collide with, so it is guaranteed to get a row of its own.
+ total += 1
+ return
+ }
+ if seenIdentities.insert(identity).inserted {
+ total += 1
+ }
+ }
+
+ adopt(FileShelfKit.adding(candidates, to: current), persist: true)
+
+ // A drop that only re-shelved things is still worth acknowledging, because otherwise the
+ // list looks untouched and the user cannot tell the drop landed at all.
+ if added == 0 {
+ statusMessage = candidates.count == 1
+ ? "“\(candidates[0].displayName)” is already on the shelf"
+ : "Already on the shelf"
+ } else {
+ statusMessage = nil
+ }
+
+ return added
+ }
+
+ // MARK: - Removal
+
+ public func remove(id: UUID) {
+ // Clearing is armed against the list the user was looking at; removing a row changes that
+ // list, so the arming is dropped rather than being carried onto a different shelf.
+ isConfirmingClearAll = false
+ adopt(FileShelfKit.removing(id: id, from: items), persist: true)
+ statusMessage = nil
+ }
+
+ /// Asks to empty the shelf. With confirmation enabled the first call only arms the action; the
+ /// second (`confirmClearAll()`) performs it.
+ public func requestClearAll() {
+ guard !items.isEmpty else { return }
+
+ if confirmsClearAll, !isConfirmingClearAll {
+ isConfirmingClearAll = true
+ return
+ }
+
+ confirmClearAll()
+ }
+
+ /// Empties the shelf. The files themselves are never touched — only this tool's references to
+ /// them — which is why this needs a confirmation rather than an undo.
+ public func confirmClearAll() {
+ isConfirmingClearAll = false
+ adopt([], persist: true)
+ statusMessage = nil
+ }
+
+ public func cancelClearAll() {
+ isConfirmingClearAll = false
+ }
+
+ // MARK: - Row actions
+
+ /// Re-resolves every entry against the file system. Cheap enough to run on every popover open,
+ /// which is what keeps a row honest after the user deleted the file behind the shelf's back.
+ public func refresh() {
+ adopt(resolvedItems(), persist: true)
+ }
+
+ /// Shows the file in Finder. An entry that no longer resolves says so instead of opening a
+ /// Finder window on nothing.
+ public func revealInFinder(id: UUID) {
+ guard let item = items.first(where: { $0.id == id }) else { return }
+
+ guard item.isAvailable, let url = item.url else {
+ statusMessage = "“\(item.displayName)” isn’t there any more"
+ return
+ }
+
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ statusMessage = nil
+ }
+
+ /// Copies the file's path. Offered even for an entry that has stopped resolving: the last
+ /// known path is often exactly what someone needs in order to go and find it.
+ public func copyPath(id: UUID) {
+ guard let item = items.first(where: { $0.id == id }) else { return }
+
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(item.displayPath, forType: .string)
+ statusMessage = "Copied path"
+ }
+
+ public func clearStatus() {
+ statusMessage = nil
+ }
+
+ public func setConfirmsClearAll(_ newValue: Bool) {
+ guard newValue != confirmsClearAll else { return }
+
+ confirmsClearAll = newValue
+ defaults.set(newValue, forKey: DefaultsKey.fileShelfConfirmsClearAll)
+ // Turning confirmation off must not silently fire the press that is already pending.
+ isConfirmingClearAll = false
+ }
+
+ /// Releases every security scope this process is holding. Called at termination: the scopes
+ /// die with the process anyway, but balancing them explicitly keeps the accounting honest and
+ /// makes a leak visible in testing rather than in a long-running session.
+ public func releaseAllAccess() {
+ accessedURLs.values.forEach { $0.stopAccessingSecurityScopedResource() }
+ accessedURLs.removeAll()
+ }
+
+ // MARK: - Private
+
+ /// Every entry re-resolved against the file system as it is right now.
+ ///
+ /// Shared by `refresh()` and `shelve(urls:)` on purpose: a drop has to be judged against the
+ /// same truth the popover would show, because anything `items` is still carrying was computed
+ /// at some earlier resolve and a file can have been moved, deleted or re-saved since.
+ private func resolvedItems() -> [ShelfItem] {
+ items.map { FileShelfKit.resolveItem($0.file) }
+ }
+
+ /// The single place `items` is assigned. It reconciles security-scoped access with the new
+ /// list and, when asked, writes the shelf out.
+ ///
+ /// The write is synchronous rather than debounced: shelf edits are a handful of deliberate
+ /// clicks, never a keystroke stream, and a menu-bar helper can be killed at any moment. Paying
+ /// a few hundred microseconds per click is the cheaper trade.
+ private func adopt(_ newItems: [ShelfItem], persist: Bool) {
+ reconcileAccess(with: newItems)
+ items = newItems
+
+ if persist {
+ FileShelfKit.write(newItems.map(\.file), to: shelfFileURL)
+ }
+ }
+
+ /// Starts a security scope for every newly-reachable entry and stops the ones that left.
+ ///
+ /// The scope is held for as long as the file is on the shelf rather than being taken and
+ /// released around each drag. The receiving app reads the file *after* the drop completes, on
+ /// its own schedule, so a scope that ended when the drag did would be gone by the time it
+ /// mattered.
+ private func reconcileAccess(with newItems: [ShelfItem]) {
+ var retained: [UUID: URL] = [:]
+
+ for item in newItems {
+ guard item.isAvailable, let url = item.url else { continue }
+
+ if let existing = accessedURLs[item.id], existing == url {
+ retained[item.id] = existing
+ accessedURLs[item.id] = nil
+ continue
+ }
+
+ // A `false` return means the URL carries no security scope — an ordinary bookmark
+ // outside the sandbox — which is not a failure and needs no matching stop.
+ if url.startAccessingSecurityScopedResource() {
+ retained[item.id] = url
+ }
+ }
+
+ // Whatever is left in `accessedURLs` belongs to a row that is gone or has moved.
+ accessedURLs.values.forEach { $0.stopAccessingSecurityScopedResource() }
+ accessedURLs = retained
+ }
+}
diff --git a/Sources/DMonteCore/FileShelfView.swift b/Sources/DMonteCore/FileShelfView.swift
new file mode 100644
index 0000000..363bf6f
--- /dev/null
+++ b/Sources/DMonteCore/FileShelfView.swift
@@ -0,0 +1,500 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// The floating File Shelf popover: a drop target that shelves whatever is dragged onto it, a list
+/// of what is parked there with Reveal / Copy Path / Remove on each row, and a Clear Shelf action.
+/// Every row is itself draggable, so the way out is the same gesture as the way in. Content is
+/// scaled to match the menu-bar/display scale so it fits the scaled panel (same approach as the
+/// other tools).
+public struct FileShelfPopoverView: View {
+ @ObservedObject var store: FileShelfStore
+ var onQuit: () -> Void
+
+ @State private var isShowingSettings = false
+ @State private var isDropTargeted = false
+
+ private let scale = FileShelfSizing.currentScale
+
+ public init(store: FileShelfStore, onQuit: @escaping () -> Void) {
+ self.store = store
+ self.onQuit = onQuit
+ }
+
+ private func s(_ value: CGFloat) -> CGFloat { value * scale }
+
+ private var accent: Color { .orange }
+
+ public var body: some View {
+ ZStack {
+ VStack(spacing: 0) {
+ header
+ Divider().opacity(0.6)
+
+ if store.items.isEmpty {
+ emptyState
+ } else {
+ shelfList
+ }
+
+ Spacer(minLength: 0)
+
+ if let status = store.statusMessage {
+ statusLine(status)
+ }
+
+ clearRow
+ footer
+ }
+ // The drop target is the whole panel, not a dedicated well inside it. People aim at
+ // the window, and a drop that lands two points outside a well is a drop that silently
+ // did nothing.
+ .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
+ shelve(from: providers)
+ }
+ .overlay {
+ if isDropTargeted {
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .strokeBorder(accent, lineWidth: s(3))
+ .allowsHitTesting(false)
+ }
+ }
+
+ if isShowingSettings {
+ PreferencesOverlay(cornerRadius: 18) {
+ FileShelfSettingsView(
+ store: store,
+ onQuit: onQuit,
+ onClose: { isShowingSettings = false }
+ )
+ }
+ }
+ }
+ .frame(width: FileShelfSizing.preferredSize().width, height: FileShelfSizing.preferredSize().height)
+ .frostedPanel(cornerRadius: 18)
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack(spacing: s(8)) {
+ Image(systemName: store.items.isEmpty ? "tray" : "tray.full.fill")
+ .font(.system(size: s(15), weight: .semibold))
+ .foregroundStyle(store.items.isEmpty ? Color.secondary : accent)
+
+ Text("File Shelf")
+ .font(.system(size: s(15), weight: .bold))
+ .foregroundStyle(.primary.opacity(0.9))
+
+ if !store.items.isEmpty {
+ Text("\(store.items.count)")
+ .font(.system(size: s(11), weight: .bold))
+ .foregroundStyle(accent)
+ .padding(.horizontal, s(6))
+ .padding(.vertical, s(2))
+ .background(
+ Capsule().fill(accent.opacity(0.16))
+ )
+ }
+
+ Spacer()
+
+ 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: - Empty state
+
+ private var emptyState: some View {
+ VStack(spacing: s(10)) {
+ Image(systemName: "tray.and.arrow.down")
+ .font(.system(size: s(34), weight: .regular))
+ .foregroundStyle(isDropTargeted ? accent : Color.secondary)
+ .symbolRenderingMode(.hierarchical)
+
+ Text("Drop files here")
+ .font(.system(size: s(14), weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ Text("Drag files onto this window or onto the menu bar icon to park them, then drag them straight back out into any app. Nothing is copied — the shelf only remembers where each file lives.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, s(24))
+ .padding(.vertical, s(30))
+ }
+
+ // MARK: - Shelf list
+
+ private var shelfList: some View {
+ ScrollView {
+ VStack(spacing: s(8)) {
+ ForEach(store.items) { item in
+ shelfRow(item)
+ }
+ }
+ .padding(.horizontal, s(16))
+ .padding(.vertical, s(12))
+ }
+ }
+
+ private func shelfRow(_ item: ShelfItem) -> some View {
+ HStack(spacing: s(9)) {
+ FileIcon(url: item.url, isAvailable: item.isAvailable, size: s(28))
+
+ VStack(alignment: .leading, spacing: s(2)) {
+ Text(item.displayName)
+ .font(.system(size: s(13), weight: .semibold))
+ .foregroundStyle(item.isAvailable ? .primary : Color.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ Text(subtitle(for: item))
+ .font(.system(size: s(10), weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.head)
+ }
+
+ Spacer(minLength: s(4))
+
+ rowButton(systemImage: "magnifyingglass", help: "Reveal in Finder", isEnabled: item.isAvailable) {
+ store.revealInFinder(id: item.id)
+ }
+ rowButton(systemImage: "doc.on.doc", help: "Copy path", isEnabled: true) {
+ store.copyPath(id: item.id)
+ }
+ rowButton(systemImage: "xmark", help: "Remove from shelf", isEnabled: true) {
+ store.remove(id: item.id)
+ }
+ }
+ .padding(.horizontal, s(9))
+ .padding(.vertical, s(7))
+ .background(
+ RoundedRectangle(cornerRadius: s(10), style: .continuous)
+ .fill(Color.secondary.opacity(item.isAvailable ? 0.12 : 0.06))
+ )
+ .opacity(item.isAvailable ? 1 : 0.65)
+ // Dragging out hands over the real file URL, so the receiving app copies/opens/attaches
+ // the actual file rather than a description of it. An unavailable row offers an empty
+ // provider: there is nothing to give, and a drag that produced a broken path would be
+ // worse than one that produces nothing.
+ .onDrag {
+ guard item.isAvailable, let url = item.url, let provider = NSItemProvider(contentsOf: url) else {
+ return NSItemProvider()
+ }
+ return provider
+ }
+ }
+
+ private func subtitle(for item: ShelfItem) -> String {
+ switch item.availability {
+ case .available:
+ // The containing folder, not the whole path: the file's own name is already on the
+ // line above, and repeating it wastes the width that would show where it lives.
+ return item.url?.deletingLastPathComponent().path ?? item.displayPath
+ case .missing:
+ // Covers both ways a real bookmark stops being followable, because the row cannot tell
+ // them apart and guessing "unplugged" at a file the user deleted reads as a bug.
+ return "Not there right now — deleted, or the disk is unplugged"
+ case .unresolvable:
+ // The only state the user has to act on: this one does not come back on its own.
+ return "Can’t be found any more — shelve it again"
+ }
+ }
+
+ private func rowButton(
+ systemImage: String,
+ help: String,
+ isEnabled: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ Image(systemName: systemImage)
+ .font(.system(size: s(10), weight: .bold))
+ .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.4))
+ .frame(width: s(22), height: s(22))
+ .background(
+ RoundedRectangle(cornerRadius: s(6), style: .continuous)
+ .fill(Color.secondary.opacity(0.14))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(6), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .disabled(!isEnabled)
+ .help(help)
+ }
+
+ // MARK: - Status
+
+ private func statusLine(_ status: String) -> some View {
+ Text(status)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ }
+
+ // MARK: - Clear
+
+ @ViewBuilder
+ private var clearRow: some View {
+ if store.isConfirmingClearAll {
+ HStack(spacing: s(8)) {
+ Button {
+ store.confirmClearAll()
+ } label: {
+ Text("Clear \(store.items.count) File\(store.items.count == 1 ? "" : "s")")
+ .font(.system(size: s(12), weight: .semibold))
+ .foregroundStyle(Color.white)
+ .frame(maxWidth: .infinity)
+ .frame(height: s(32))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(accent)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ store.cancelClearAll()
+ } label: {
+ Text("Cancel")
+ .font(.system(size: s(12), weight: .semibold))
+ .foregroundStyle(.primary)
+ .frame(width: s(78))
+ .frame(height: s(32))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(Color.secondary.opacity(0.14))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ } else if !store.items.isEmpty {
+ Button {
+ store.requestClearAll()
+ } label: {
+ HStack(spacing: s(8)) {
+ Image(systemName: "tray.and.arrow.up")
+ .font(.system(size: s(13), weight: .semibold))
+ Text("Clear Shelf")
+ .font(.system(size: s(13), weight: .semibold))
+ }
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity)
+ .frame(height: s(34))
+ .background(
+ RoundedRectangle(cornerRadius: s(8), style: .continuous)
+ .fill(Color.secondary.opacity(0.14))
+ )
+ .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .help("Take everything off the shelf. The files themselves are not touched.")
+ .padding(.horizontal, s(16))
+ .padding(.bottom, s(8))
+ }
+ }
+
+ // MARK: - Footer
+
+ private var footer: some View {
+ HStack {
+ Button {
+ store.refresh()
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ .font(.system(size: s(12), weight: .semibold))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .help("Re-check every file on the shelf")
+
+ Spacer()
+
+ Button {
+ onQuit()
+ } label: {
+ Label("Quit", systemImage: "power")
+ .font(.system(size: s(12), weight: .semibold))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, s(16))
+ .padding(.top, s(4))
+ .padding(.bottom, s(14))
+ }
+
+ // MARK: - Drop handling
+
+ /// Pulls file URLs out of a SwiftUI drop and shelves them.
+ private func shelve(from providers: [NSItemProvider]) -> Bool {
+ let fileProviders = providers.filter {
+ $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)
+ }
+ guard !fileProviders.isEmpty else { return false }
+
+ let collector = DroppedURLCollector(expected: fileProviders.count, store: store)
+ for (index, provider) in fileProviders.enumerated() {
+ // The completion handler runs off the main actor. `collector` is a main-actor class
+ // and therefore Sendable, so it can cross the boundary; the hop back onto the main
+ // actor is what makes touching its state legal.
+ _ = provider.loadObject(ofClass: URL.self) { url, _ in
+ Task { @MainActor in
+ collector.accept(url, at: index)
+ }
+ }
+ }
+
+ return true
+ }
+}
+
+/// Gathers the URLs of one drag and shelves them together, in the order the drag carried them.
+///
+/// The providers in a multi-file drag answer in whatever order they feel like, and shelving each
+/// one as it arrived would land the files on the shelf in an order that has nothing to do with
+/// what the user selected. Waiting for the last one costs effectively nothing — a provider
+/// carrying a file URL hands over the URL, it does not read the file — and it makes a drop into
+/// the popover behave exactly like a drop onto the menu-bar icon, which reads the pasteboard in
+/// one go and is ordered for free.
+@MainActor
+private final class DroppedURLCollector {
+ private var urls: [URL?]
+ private var remaining: Int
+ private let store: FileShelfStore
+
+ init(expected: Int, store: FileShelfStore) {
+ urls = Array(repeating: nil, count: expected)
+ remaining = expected
+ self.store = store
+ }
+
+ /// Records one provider's answer, and shelves the batch once every provider has replied. A
+ /// provider that failed contributes `nil` and is simply left out; it must still be counted, or
+ /// one unreadable item in a drag would strand the whole batch.
+ func accept(_ url: URL?, at index: Int) {
+ guard remaining > 0, urls.indices.contains(index) else { return }
+
+ urls[index] = url
+ remaining -= 1
+ guard remaining == 0 else { return }
+
+ let resolved = urls.compactMap { $0 }
+ guard !resolved.isEmpty else { return }
+ store.shelve(urls: resolved)
+ }
+}
+
+/// The Finder icon for a shelved file, which is what people actually recognise a file by. An
+/// entry that no longer resolves falls back to a generic question-marked document rather than
+/// borrowing the icon of whatever happens to sit at its old path now.
+private struct FileIcon: View {
+ var url: URL?
+ var isAvailable: Bool
+ var size: CGFloat
+
+ var body: some View {
+ if isAvailable, let url {
+ Image(nsImage: NSWorkspace.shared.icon(forFile: url.path))
+ .resizable()
+ .frame(width: size, height: size)
+ } else {
+ Image(systemName: "questionmark.folder")
+ .font(.system(size: size * 0.7, weight: .regular))
+ .foregroundStyle(.secondary)
+ .symbolRenderingMode(.hierarchical)
+ .frame(width: size, height: size)
+ }
+ }
+}
+
+// MARK: - Settings
+
+private struct FileShelfSettingsView: View {
+ @ObservedObject var store: FileShelfStore
+ var onQuit: () -> Void
+ var onClose: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("File Shelf 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)
+ }
+
+ settingRow(title: "Confirm before clearing") {
+ GreenSwitch(isOn: Binding(
+ get: { store.confirmsClearAll },
+ set: { store.setConfirmsClearAll($0) }
+ ))
+ }
+
+ Text("When on, Clear Shelf asks for a second press before emptying the shelf.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Divider()
+
+ Text("The shelf never copies anything. Each entry is a bookmark to the original file, so it keeps working after you rename or move the file, and removing a row leaves the file exactly where it is. A file that has been deleted, or that lives on a disk you have unplugged, stays on the shelf and is shown as unavailable rather than disappearing.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Divider()
+
+ Button(role: .destructive) {
+ onClose()
+ onQuit()
+ } label: {
+ Label("Quit File Shelf", systemImage: "power")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ Spacer()
+ }
+ .padding(20)
+ .frame(width: FileShelfSizing.settingsSize().width, height: FileShelfSizing.settingsSize().height)
+ }
+
+ private func settingRow(title: String, @ViewBuilder trailing: () -> Trailing) -> some View {
+ HStack {
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ Spacer()
+ trailing()
+ }
+ }
+}
diff --git a/Sources/DMonteCore/StatusBarButtonContent.swift b/Sources/DMonteCore/StatusBarButtonContent.swift
index 291f05a..58844dc 100644
--- a/Sources/DMonteCore/StatusBarButtonContent.swift
+++ b/Sources/DMonteCore/StatusBarButtonContent.swift
@@ -87,6 +87,31 @@ public enum StatusBarButtonContent {
}
}
+ /// Draws an icon with a short count badge beside it (File Shelf's "how many files are parked
+ /// here?"). Unlike `updateTitle`, the icon stays: the badge annotates the tool, it does not
+ /// replace it, and a bare number in the menu bar says nothing about which tool it belongs to.
+ ///
+ /// Pass `nil`/empty to drop back to the icon alone, so an empty shelf does not hold menu-bar
+ /// width open for a permanent "0".
+ @MainActor
+ public static func updateBadge(_ badge: String?, image: NSImage, in item: NSStatusItem) {
+ guard let button = item.button else { return }
+ image.isTemplate = true
+ button.image = image
+
+ if let badge, !badge.isEmpty {
+ button.title = badge
+ button.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
+ button.imagePosition = .imageLeading
+ // Without this the glyph and the number sit flush against each other and read as one
+ // smudge at menu-bar size.
+ button.imageHugsTitle = true
+ } else {
+ button.title = ""
+ button.imagePosition = .imageOnly
+ }
+ }
+
/// Shows a lightweight context menu for a status item on right-click or control+left-click
/// (the canonical macOS right-click equivalent, and the only option on some trackpad
/// configurations) while preserving the plain left-click action used by popovers/windows.
diff --git a/Sources/DMonteCore/StatusItemFileDropView.swift b/Sources/DMonteCore/StatusItemFileDropView.swift
new file mode 100644
index 0000000..727ca83
--- /dev/null
+++ b/Sources/DMonteCore/StatusItemFileDropView.swift
@@ -0,0 +1,117 @@
+import AppKit
+
+/// A transparent overlay that turns a menu-bar status item's button into a file drop target.
+///
+/// `NSStatusBarButton` is created by AppKit — we never get to subclass it — and `NSView`'s
+/// dragging-destination methods are overrides, not delegate callbacks, so there is no way to make
+/// the button itself accept a drag. Covering it with a child view that registers for file URLs is
+/// the way in.
+///
+/// The catch is that the child would otherwise swallow the click that opens the popover, so every
+/// mouse event it receives is handed straight back to the button underneath. The button then runs
+/// its normal tracking loop and fires its target/action exactly as if this view were not here,
+/// which keeps left-click-to-open and right-click-to-quit working unchanged.
+@MainActor
+public final class StatusItemFileDropView: NSView {
+ /// Called on the main actor with the file URLs of a completed drop.
+ public var onDrop: (([URL]) -> Void)?
+
+ /// Called as the drag enters and leaves, so the icon can show that it is a live target.
+ public var onDragTargetingChanged: ((Bool) -> Void)?
+
+ public override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ registerForDraggedTypes([.fileURL])
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("StatusItemFileDropView is created in code only")
+ }
+
+ /// Installs the overlay over `button`, sized to follow it. Returns the view so the caller can
+ /// keep hold of it; the button owns it as a subview either way.
+ @discardableResult
+ public static func install(over button: NSStatusBarButton) -> StatusItemFileDropView {
+ let dropView = StatusItemFileDropView(frame: button.bounds)
+ dropView.autoresizingMask = [.width, .height]
+ button.addSubview(dropView)
+ return dropView
+ }
+
+ // MARK: - Mouse pass-through
+
+ // Every one of these hands the event back to the button underneath. Without them the overlay
+ // is a dead zone over the icon and the tool cannot be opened at all.
+
+ /// This helper is `LSUIElement` and never becomes the active app, so *every* click on its
+ /// menu-bar icon is a first-mouse click.
+ ///
+ /// The overlay wins hit-testing over the button it covers, and `NSView` answers `false` here by
+ /// default where `NSStatusBarButton` answers `true`. When AppKit asks the hit view and gets
+ /// `false`, it spends the mouse-down on activating the application instead of delivering it —
+ /// so `mouseDown(with:)` below is never called, the pass-through never runs, and the popover
+ /// does not open. The other two status-button subviews in this package override this for the
+ /// same reason.
+ public override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ public override func mouseDown(with event: NSEvent) {
+ superview?.mouseDown(with: event)
+ }
+
+ public override func rightMouseDown(with event: NSEvent) {
+ superview?.rightMouseDown(with: event)
+ }
+
+ public override func mouseUp(with event: NSEvent) {
+ superview?.mouseUp(with: event)
+ }
+
+ public override func rightMouseUp(with event: NSEvent) {
+ superview?.rightMouseUp(with: event)
+ }
+
+ // MARK: - Dragging destination
+
+ public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
+ guard !Self.fileURLs(from: sender).isEmpty else { return [] }
+ onDragTargetingChanged?(true)
+ // `.copy` describes the drag cursor, not what happens to the file: shelving records a
+ // bookmark and never touches the bytes. `.link` would be closer to the truth but reads to
+ // the source app as "make an alias", which is not what is being asked for either.
+ return .copy
+ }
+
+ public override func draggingExited(_ sender: NSDraggingInfo?) {
+ onDragTargetingChanged?(false)
+ }
+
+ public override func draggingEnded(_ sender: NSDraggingInfo) {
+ onDragTargetingChanged?(false)
+ }
+
+ public override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ !Self.fileURLs(from: sender).isEmpty
+ }
+
+ public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ let urls = Self.fileURLs(from: sender)
+ onDragTargetingChanged?(false)
+ guard !urls.isEmpty else { return false }
+
+ onDrop?(urls)
+ return true
+ }
+
+ /// The file URLs carried by a drag, in the order the source assembled them.
+ ///
+ /// `urlReadingFileURLsOnly` is what keeps a dragged web link or a text selection from arriving
+ /// as a shelf entry pointing at nothing.
+ static func fileURLs(from sender: NSDraggingInfo) -> [URL] {
+ let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
+ let objects = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: options)
+ return (objects as? [URL]) ?? []
+ }
+}
diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift
index ae79d39..2eeb0d6 100644
--- a/Sources/DMonteCore/ToolboxCatalog.swift
+++ b/Sources/DMonteCore/ToolboxCatalog.swift
@@ -69,7 +69,8 @@ public enum ToolboxCatalog {
ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"]),
ToolboxTool(id: "driveEjector", title: "Drive Ejector", iconName: "eject.fill", tint: .teal, bundleID: prefix + "driveejector", appName: "DMonte Drive Ejector.app", executableName: "DMonteDriveEjector", arguments: ["--open"]),
ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "square.and.pencil", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", arguments: ["--open"]),
- ToolboxTool(id: "networkInfo", title: "Network Info", iconName: "network", tint: .teal, bundleID: prefix + "networkinfo", appName: "DMonte Network Info.app", executableName: "DMonteNetworkInfo", arguments: ["--open"])
+ ToolboxTool(id: "networkInfo", title: "Network Info", iconName: "network", tint: .teal, bundleID: prefix + "networkinfo", appName: "DMonte Network Info.app", executableName: "DMonteNetworkInfo", arguments: ["--open"]),
+ ToolboxTool(id: "fileShelf", title: "File Shelf", iconName: "tray.full.fill", tint: .orange, bundleID: prefix + "fileshelf", appName: "DMonte File Shelf.app", executableName: "DMonteFileShelf", arguments: ["--open"])
]
}
diff --git a/Sources/DMonteFileShelfApp/FileShelfAppDelegate.swift b/Sources/DMonteFileShelfApp/FileShelfAppDelegate.swift
new file mode 100644
index 0000000..680e06a
--- /dev/null
+++ b/Sources/DMonteFileShelfApp/FileShelfAppDelegate.swift
@@ -0,0 +1,146 @@
+import AppKit
+import Combine
+import DMonteCore
+import SwiftUI
+
+/// Distributed notification used to reveal this helper's popover when the Toolbox (or a second
+/// launch with `--open`) asks for it.
+enum FileShelfNotifications {
+ static let showWindow = Notification.Name("com.havokentity.mactools.fileshelf.showWindow")
+}
+
+@MainActor
+final class FileShelfAppDelegate: NSObject, NSApplicationDelegate {
+ /// Built lazily, on the first access in `applicationDidFinishLaunching` — i.e. *after*
+ /// `AppDefaults.registerDefaults()`. The store reads the Clear Shelf confirmation preference
+ /// in `init`, and a registration domain only exists once registered in this process, so an
+ /// eagerly-created store would read `false` and ship the one destructive action here with its
+ /// confirmation turned off.
+ private lazy var store = FileShelfStore()
+
+ private var statusItem: HelperStatusItem?
+ private var panelHost: HelperPanelHost?
+ private var dropView: StatusItemFileDropView?
+ private var cancellables: Set = []
+
+ /// `true` while a drag is hovering the menu-bar icon, so the glyph can show it is a live
+ /// target — the icon is the only feedback available before the popover is even open.
+ private var isDragTargetingStatusItem = false
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ AppDefaults.registerDefaults()
+
+ let host = HelperPanelHost(
+ configuration: HelperPanelHost.Configuration(
+ sizing: .preferred({ FileShelfSizing.preferredSize() })
+ ),
+ content: .viewController({ [store, weak self] in
+ NSHostingController(
+ rootView: FileShelfPopoverView(store: store, onQuit: { self?.quit() })
+ )
+ }),
+ anchorView: { [weak self] in self?.statusItem?.button }
+ )
+ panelHost = host
+
+ // Files get deleted, renamed and unplugged while the popover is closed, so every entry is
+ // re-checked on open rather than only when something changes in-process. Cheap, and it
+ // makes a row that lies about a missing file impossible to see.
+ host.onWillShow = { [store] in
+ store.clearStatus()
+ // An armed Clear Shelf must not survive the panel closing: reopening minutes later
+ // onto a primed "Clear 12 Files" button is one stray click from an empty shelf.
+ store.cancelClearAll()
+ store.refresh()
+ }
+ host.configure()
+
+ statusItem = HelperStatusItem(
+ image: Self.statusIcon(hasFiles: false, isDragTargeted: false),
+ toolTip: "File Shelf — drop files here to park them",
+ primaryAction: { [weak self] in self?.panelHost?.toggle() },
+ quitAction: { [weak self] in self?.quit() }
+ )
+
+ installStatusItemDropTarget()
+
+ // Reflect whatever survived the last run, before any change arrives.
+ refreshStatusBadge()
+
+ host.observeShowNotification(named: FileShelfNotifications.showWindow)
+ observeStoreState()
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ panelHost?.stopObservingShowNotifications()
+ panelHost?.removeOutsideClickMonitor()
+ cancellables.removeAll()
+ // The shelf itself is written synchronously on every edit, so there is nothing to flush —
+ // only the security scopes this process is holding open need balancing.
+ store.releaseAllAccess()
+ panelHost?.dismissForTermination()
+ statusItem?.remove()
+ }
+
+ // MARK: - Status item
+
+ /// The two-state tray glyph: a filled tray while files are parked, an empty one otherwise, and
+ /// a downward arrow while a drag is hovering so the user can see the icon will accept it.
+ /// Forced to template so AppKit tints it adaptive white and gives it the native rollover
+ /// highlight.
+ private static func statusIcon(hasFiles: Bool, isDragTargeted: Bool) -> NSImage {
+ let name: String
+ if isDragTargeted {
+ name = "tray.and.arrow.down.fill"
+ } else {
+ name = hasFiles ? "tray.full.fill" : "tray"
+ }
+
+ let image = NSImage(systemSymbolName: name, accessibilityDescription: "File Shelf") ?? NSImage()
+ image.isTemplate = true
+ return image
+ }
+
+ /// Lets the menu-bar icon itself accept a drag, which is the point of the tool: park a file
+ /// without opening anything, from whatever app you were already in.
+ private func installStatusItemDropTarget() {
+ guard let button = statusItem?.button else { return }
+
+ let dropView = StatusItemFileDropView.install(over: button)
+ dropView.onDrop = { [weak self] urls in
+ self?.store.shelve(urls: urls)
+ }
+ dropView.onDragTargetingChanged = { [weak self] isTargeted in
+ guard let self, self.isDragTargetingStatusItem != isTargeted else { return }
+ self.isDragTargetingStatusItem = isTargeted
+ self.refreshStatusBadge()
+ }
+ self.dropView = dropView
+ }
+
+ /// Keeps the menu-bar glyph and its count in step with the shelf, so "how many things am I
+ /// carrying?" is answered without opening the popover.
+ private func refreshStatusBadge() {
+ guard let item = statusItem?.item else { return }
+
+ StatusBarButtonContent.updateBadge(
+ FileShelfKit.badgeTitle(for: store.items.count),
+ image: Self.statusIcon(hasFiles: !store.items.isEmpty, isDragTargeted: isDragTargetingStatusItem),
+ in: item
+ )
+ }
+
+ private func observeStoreState() {
+ store.$items
+ .receive(on: RunLoop.main)
+ .sink { [weak self] _ in
+ self?.refreshStatusBadge()
+ }
+ .store(in: &cancellables)
+ }
+
+ private func quit() {
+ panelHost?.close()
+ NSApp.terminate(nil)
+ }
+}
diff --git a/Sources/DMonteFileShelfApp/main.swift b/Sources/DMonteFileShelfApp/main.swift
new file mode 100644
index 0000000..e3309dc
--- /dev/null
+++ b/Sources/DMonteFileShelfApp/main.swift
@@ -0,0 +1,24 @@
+import AppKit
+import DMonteCore
+
+let singleInstanceGuard = SingleInstanceGuard(identifier: "com.havokentity.mactools.fileshelf")
+
+guard singleInstanceGuard.isPrimary else {
+ if CommandLine.arguments.contains("--open") {
+ DistributedNotificationCenter.default().postNotificationName(
+ FileShelfNotifications.showWindow,
+ object: nil,
+ userInfo: nil,
+ deliverImmediately: true
+ )
+ }
+
+ exit(EXIT_SUCCESS)
+}
+
+let app = NSApplication.shared
+let delegate = FileShelfAppDelegate()
+
+app.delegate = delegate
+app.setActivationPolicy(.accessory)
+app.run()
diff --git a/Tests/DMonteCoreTests/FileShelfKitTests.swift b/Tests/DMonteCoreTests/FileShelfKitTests.swift
new file mode 100644
index 0000000..fab80a3
--- /dev/null
+++ b/Tests/DMonteCoreTests/FileShelfKitTests.swift
@@ -0,0 +1,387 @@
+import Foundation
+import XCTest
+@testable import DMonteCore
+
+/// Covers the promises the File Shelf makes about the files it is holding: that a bookmark still
+/// finds a file after it has been renamed or moved, that a file which is genuinely gone shows up
+/// as unavailable instead of vanishing from the list, that dropping the same file twice does not
+/// produce two rows, and that the shelf survives a relaunch.
+///
+/// Bookmarks are exercised for real against files in a temporary directory rather than through a
+/// stub, because "does a bookmark actually follow a rename?" is the entire reason this tool stores
+/// bookmarks instead of paths — a fake resolver would assert nothing about that.
+final class FileShelfKitTests: XCTestCase {
+ private var directory: URL!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent("FileShelfKitTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+
+ override func tearDownWithError() throws {
+ if let directory {
+ try? FileManager.default.removeItem(at: directory)
+ }
+ directory = nil
+ try super.tearDownWithError()
+ }
+
+ @discardableResult
+ private func makeFile(named name: String, contents: String = "shelf") throws -> URL {
+ let url = directory.appendingPathComponent(name)
+ try Data(contents.utf8).write(to: url)
+ return url
+ }
+
+ /// A shelf item that resolves to nothing, built without touching the file system. Used for the
+ /// pure ordering/de-duplication tests, where the file system would only add noise.
+ private func makeUnresolvableItem(named name: String, shelved: Date = Date()) -> ShelfItem {
+ ShelfItem(
+ file: ShelvedFile(
+ bookmark: Data(),
+ displayName: name,
+ lastKnownPath: "/nowhere/\(name)",
+ shelved: shelved
+ ),
+ url: nil,
+ availability: .unresolvable,
+ identity: nil
+ )
+ }
+
+ private func makeItem(identity: String, name: String) -> ShelfItem {
+ ShelfItem(
+ file: ShelvedFile(
+ bookmark: Data(),
+ displayName: name,
+ lastKnownPath: "/somewhere/\(name)",
+ shelved: Date()
+ ),
+ url: URL(fileURLWithPath: "/somewhere/\(name)"),
+ availability: .available,
+ identity: identity
+ )
+ }
+
+ // MARK: - Badge
+
+ /// An empty shelf must not hold menu-bar width open for a permanent "0".
+ func testBadgeIsAbsentWhenTheShelfIsEmpty() {
+ XCTAssertNil(FileShelfKit.badgeTitle(for: 0))
+ XCTAssertNil(FileShelfKit.badgeTitle(for: -1))
+ }
+
+ func testBadgeCountsUpToTheCapAndThenSaysSo() {
+ XCTAssertEqual(FileShelfKit.badgeTitle(for: 1), "1")
+ XCTAssertEqual(FileShelfKit.badgeTitle(for: 42), "42")
+ XCTAssertEqual(FileShelfKit.badgeTitle(for: FileShelfKit.maximumBadgeCount), "99")
+ XCTAssertEqual(FileShelfKit.badgeTitle(for: FileShelfKit.maximumBadgeCount + 1), "99+")
+ }
+
+ // MARK: - Identity
+
+ /// De-duplication compares files, not spellings. `/tmp` is a symlink to `/private/tmp` on
+ /// every Mac, so the same file reached both ways has to come out as one identity or a drop
+ /// from Finder and a drop from a terminal-launched app would produce two rows.
+ func testIdentityIsTheSameThroughASymlinkedPath() throws {
+ let real = try makeFile(named: "target.txt")
+ let linkDirectory = directory.appendingPathComponent("link", isDirectory: true)
+ try FileManager.default.createSymbolicLink(at: linkDirectory, withDestinationURL: directory)
+ let viaLink = linkDirectory.appendingPathComponent("target.txt")
+
+ XCTAssertEqual(FileShelfKit.identity(for: real), FileShelfKit.identity(for: viaLink))
+ }
+
+ /// Renaming is exactly the kind of tidying people do while a file is parked on the shelf, so
+ /// the identity has to travel with the file rather than with its name.
+ func testIdentitySurvivesARename() throws {
+ let original = try makeFile(named: "before.txt")
+ let before = FileShelfKit.identity(for: original)
+
+ let renamed = directory.appendingPathComponent("after.txt")
+ try FileManager.default.moveItem(at: original, to: renamed)
+
+ XCTAssertEqual(FileShelfKit.identity(for: renamed), before)
+ }
+
+ func testDifferentFilesHaveDifferentIdentities() throws {
+ let first = try makeFile(named: "one.txt")
+ let second = try makeFile(named: "two.txt")
+
+ XCTAssertNotEqual(FileShelfKit.identity(for: first), FileShelfKit.identity(for: second))
+ }
+
+ // MARK: - Bookmarks
+
+ func testBookmarkRoundTripsToTheSameFile() throws {
+ let url = try makeFile(named: "round-trip.txt")
+
+ let bookmark = try XCTUnwrap(FileShelfKit.bookmarkData(for: url))
+ let resolved = try XCTUnwrap(FileShelfKit.resolve(bookmark))
+
+ XCTAssertEqual(
+ resolved.url.resolvingSymlinksInPath().path,
+ url.resolvingSymlinksInPath().path
+ )
+ }
+
+ /// The headline promise of storing bookmarks instead of paths: rename the file in Finder and
+ /// the shelf entry still points at it, under its new name.
+ func testAShelvedEntryFollowsTheFileThroughARename() throws {
+ let original = try makeFile(named: "draft.txt")
+ let item = FileShelfKit.makeItem(for: original)
+
+ let renamed = directory.appendingPathComponent("final.txt")
+ try FileManager.default.moveItem(at: original, to: renamed)
+
+ let resolved = FileShelfKit.resolveItem(item.file)
+
+ XCTAssertEqual(resolved.availability, .available)
+ XCTAssertEqual(resolved.displayName, "final.txt")
+ XCTAssertEqual(
+ resolved.url?.resolvingSymlinksInPath().path,
+ renamed.resolvingSymlinksInPath().path
+ )
+ }
+
+ /// Moving a file to another folder is the other half of the same promise.
+ func testAShelvedEntryFollowsTheFileIntoAnotherFolder() throws {
+ let original = try makeFile(named: "moving.txt")
+ let item = FileShelfKit.makeItem(for: original)
+
+ let subdirectory = directory.appendingPathComponent("archive", isDirectory: true)
+ try FileManager.default.createDirectory(at: subdirectory, withIntermediateDirectories: true)
+ let moved = subdirectory.appendingPathComponent("moving.txt")
+ try FileManager.default.moveItem(at: original, to: moved)
+
+ let resolved = FileShelfKit.resolveItem(item.file)
+
+ XCTAssertEqual(resolved.availability, .available)
+ XCTAssertEqual(
+ resolved.url?.resolvingSymlinksInPath().path,
+ moved.resolvingSymlinksInPath().path
+ )
+ }
+
+ /// Bookmark data that is simply not bookmark data. This is the deterministic form of "the
+ /// bookmark no longer resolves": the entry must survive as an unavailable row, keeping the
+ /// name and path it was shelved under so the user can still see what they lost.
+ func testAnUnresolvableBookmarkBecomesAnUnavailableEntryRatherThanDisappearing() {
+ let file = ShelvedFile(
+ bookmark: Data([0x00, 0x01, 0x02, 0x03]),
+ displayName: "gone.txt",
+ lastKnownPath: "/Users/somebody/Documents/gone.txt"
+ )
+
+ let resolved = FileShelfKit.resolveItem(file)
+
+ XCTAssertEqual(resolved.availability, .unresolvable)
+ XCTAssertFalse(resolved.isAvailable)
+ XCTAssertNil(resolved.url)
+ XCTAssertNil(resolved.identity)
+ // The row still has something to say for itself.
+ XCTAssertEqual(resolved.displayName, "gone.txt")
+ XCTAssertEqual(resolved.displayPath, "/Users/somebody/Documents/gone.txt")
+ }
+
+ /// Empty bookmark data is what a file that could not be bookmarked at all leaves behind, and
+ /// it must not resolve to anything.
+ func testEmptyBookmarkDataDoesNotResolve() {
+ XCTAssertNil(FileShelfKit.resolve(Data()))
+ }
+
+ /// A file that is deleted while it sits on the shelf must never be silently pruned: the row
+ /// is what tells the user the thing they parked is gone.
+ func testADeletedFileStaysOnTheShelfAsAnUnavailableEntry() throws {
+ let url = try makeFile(named: "doomed.txt")
+ let item = FileShelfKit.makeItem(for: url)
+ XCTAssertTrue(item.isAvailable)
+
+ try FileManager.default.removeItem(at: url)
+
+ let resolved = FileShelfKit.resolveItem(item.file)
+
+ XCTAssertFalse(resolved.isAvailable, "A deleted file must read as unavailable, not as available")
+ XCTAssertNotEqual(resolved.availability, .available)
+ XCTAssertNil(resolved.identity, "An entry with no file behind it must not be able to collide with anything")
+ XCTAssertEqual(resolved.displayName, "doomed.txt")
+ }
+
+ /// The two unavailable states mean different things to the user — `.missing` is "come back
+ /// when the disk is plugged in", `.unresolvable` is "shelve it again, this one is not coming
+ /// back" — and they have to stay told apart.
+ ///
+ /// It is tempting to read "the bookmark did not resolve" as `.unresolvable`, but resolution
+ /// fails identically for a real bookmark whose file is gone or whose volume is unmounted
+ /// (`.withoutMounting` makes sure of the second). Calling that junk would tell someone who
+ /// merely unplugged an external disk that the row is dead, and they would remove an entry that
+ /// was about to start working again by itself.
+ func testABookmarkThatCannotBeFollowedIsMissingRatherThanJunk() throws {
+ let url = try makeFile(named: "on-a-disk-that-left.txt")
+ let item = FileShelfKit.makeItem(for: url)
+ try FileManager.default.removeItem(at: url)
+
+ let resolved = FileShelfKit.resolveItem(item.file)
+
+ XCTAssertEqual(
+ resolved.availability, .missing,
+ "A bookmark that still names a file is recoverable, so it must not be reported as unrecoverable"
+ )
+ }
+
+ /// …and the converse, so the distinction is not simply always `.missing`: bytes that are not
+ /// bookmark data at all name nothing, and only re-shelving fixes that.
+ func testDataThatIsNotABookmarkAtAllIsUnresolvableRatherThanMissing() {
+ let junk = ShelvedFile(
+ bookmark: Data([0x01, 0x02, 0x03, 0x04, 0x05]),
+ displayName: "corrupt.txt",
+ lastKnownPath: "/Users/somebody/Documents/corrupt.txt"
+ )
+ let never = ShelvedFile(
+ bookmark: Data(),
+ displayName: "never-bookmarked.txt",
+ lastKnownPath: "/Users/somebody/Documents/never-bookmarked.txt"
+ )
+
+ XCTAssertEqual(FileShelfKit.resolveItem(junk).availability, .unresolvable)
+ XCTAssertEqual(FileShelfKit.resolveItem(never).availability, .unresolvable)
+ }
+
+ // MARK: - Adding and ordering
+
+ /// Newest first: the shelf is read from the top, and the thing just dropped is the thing most
+ /// likely to be dragged straight back out.
+ func testAddingPutsTheNewestEntryFirst() {
+ var items: [ShelfItem] = []
+ items = FileShelfKit.adding(makeItem(identity: "1:1", name: "first.txt"), to: items)
+ items = FileShelfKit.adding(makeItem(identity: "1:2", name: "second.txt"), to: items)
+ items = FileShelfKit.adding(makeItem(identity: "1:3", name: "third.txt"), to: items)
+
+ XCTAssertEqual(items.map(\.displayName), ["third.txt", "second.txt", "first.txt"])
+ }
+
+ /// A multi-file drag arrives as an ordered list, and the order the user assembled is the order
+ /// they expect to see — first file selected at the top, not reversed.
+ func testAddingABatchPreservesTheOrderTheFilesWereDroppedIn() {
+ let batch = [
+ makeItem(identity: "1:1", name: "a.txt"),
+ makeItem(identity: "1:2", name: "b.txt"),
+ makeItem(identity: "1:3", name: "c.txt")
+ ]
+
+ let items = FileShelfKit.adding(batch, to: [])
+
+ XCTAssertEqual(items.map(\.displayName), ["a.txt", "b.txt", "c.txt"])
+ }
+
+ /// The same file dropped twice is one row, not two.
+ func testAddingTheSameFileTwiceKeepsASingleRow() throws {
+ let url = try makeFile(named: "twice.txt")
+
+ var items = FileShelfKit.adding(FileShelfKit.makeItem(for: url), to: [])
+ items = FileShelfKit.adding(FileShelfKit.makeItem(for: url), to: items)
+
+ XCTAssertEqual(items.count, 1)
+ XCTAssertEqual(items[0].displayName, "twice.txt")
+ }
+
+ /// …and it is still one row when the second drop names the file differently, because the file
+ /// was renamed in between. The shelf follows files, so it has to de-duplicate on files too.
+ func testAddingAFileAgainUnderANewNameStillKeepsASingleRow() throws {
+ let original = try makeFile(named: "before.txt")
+ var items = FileShelfKit.adding(FileShelfKit.makeItem(for: original), to: [])
+
+ let renamed = directory.appendingPathComponent("after.txt")
+ try FileManager.default.moveItem(at: original, to: renamed)
+
+ // Re-resolve first, exactly as the store does on every popover open, so the shelved row
+ // knows the file's new location before the second drop is compared against it.
+ items = items.map { FileShelfKit.resolveItem($0.file) }
+ items = FileShelfKit.adding(FileShelfKit.makeItem(for: renamed), to: items)
+
+ XCTAssertEqual(items.count, 1)
+ XCTAssertEqual(items[0].displayName, "after.txt")
+ }
+
+ /// Re-dropping something already shelved moves it back to the top — a drop that appeared to do
+ /// nothing at all reads as a drop that was not registered — but keeps the original row's id so
+ /// the list does not churn.
+ func testRedroppingAnExistingFileMovesItToTheTopAndKeepsItsIdentity() {
+ var items = FileShelfKit.adding(makeItem(identity: "1:1", name: "old.txt"), to: [])
+ let originalID = items[0].id
+ items = FileShelfKit.adding(makeItem(identity: "1:2", name: "new.txt"), to: items)
+ XCTAssertEqual(items.map(\.displayName), ["new.txt", "old.txt"])
+
+ items = FileShelfKit.adding(makeItem(identity: "1:1", name: "old.txt"), to: items)
+
+ XCTAssertEqual(items.map(\.displayName), ["old.txt", "new.txt"])
+ XCTAssertEqual(items[0].id, originalID, "A re-drop must reuse the existing row rather than mint a new identity")
+ }
+
+ /// Two entries that both failed to resolve are not "the same file" just because neither could
+ /// be found; collapsing them would hide one of the two things the user actually lost.
+ func testEntriesWithoutAnIdentityNeverCollapseIntoEachOther() {
+ var items = FileShelfKit.adding(makeUnresolvableItem(named: "one.txt"), to: [])
+ items = FileShelfKit.adding(makeUnresolvableItem(named: "two.txt"), to: items)
+
+ XCTAssertEqual(items.count, 2)
+ }
+
+ /// The cap is applied from the bottom, so an add never fails — the oldest entry makes room.
+ func testTheOldestEntryFallsOffOnceTheShelfIsFull() {
+ var items: [ShelfItem] = []
+ for index in 0..<(FileShelfKit.maximumCount + 5) {
+ items = FileShelfKit.adding(makeItem(identity: "1:\(index)", name: "file-\(index).txt"), to: items)
+ }
+
+ XCTAssertEqual(items.count, FileShelfKit.maximumCount)
+ XCTAssertEqual(items.first?.displayName, "file-\(FileShelfKit.maximumCount + 4).txt")
+ XCTAssertEqual(items.last?.displayName, "file-5.txt")
+ }
+
+ func testRemovingTakesOnlyTheNamedRow() throws {
+ var items = FileShelfKit.adding(makeItem(identity: "1:1", name: "keep.txt"), to: [])
+ items = FileShelfKit.adding(makeItem(identity: "1:2", name: "drop.txt"), to: items)
+ let doomed = try XCTUnwrap(items.first { $0.displayName == "drop.txt" }).id
+
+ let remaining = FileShelfKit.removing(id: doomed, from: items)
+
+ XCTAssertEqual(remaining.map(\.displayName), ["keep.txt"])
+ }
+
+ // MARK: - Persistence
+
+ /// The on-disk round trip, including the bookmark bytes: a shelf that did not survive a quit
+ /// would be a scratch list, not a shelf.
+ func testTheShelfSurvivesAWriteAndLoadRoundTrip() throws {
+ let first = try makeFile(named: "one.txt")
+ let second = try makeFile(named: "two.txt")
+ var items = FileShelfKit.adding(FileShelfKit.makeItem(for: first), to: [])
+ items = FileShelfKit.adding(FileShelfKit.makeItem(for: second), to: items)
+
+ let shelfFile = directory.appendingPathComponent("shelf.json")
+ XCTAssertTrue(FileShelfKit.write(items.map(\.file), to: shelfFile))
+
+ let reloaded = FileShelfKit.load(from: shelfFile)
+
+ XCTAssertEqual(reloaded.map(\.id), items.map(\.id))
+ XCTAssertEqual(reloaded.map(\.displayName), ["two.txt", "one.txt"])
+
+ // The bookmarks have to come back usable, not merely present: a shelf that reloads rows
+ // which no longer resolve is indistinguishable from one that lost them.
+ let resolved = reloaded.map { FileShelfKit.resolveItem($0) }
+ XCTAssertEqual(resolved.map(\.availability), [.available, .available])
+ }
+
+ /// A shelf file that is missing (first launch) or corrupt is an empty shelf, never a crash.
+ func testLoadingAMissingOrCorruptShelfYieldsAnEmptyShelf() throws {
+ let missing = directory.appendingPathComponent("not-there.json")
+ XCTAssertTrue(FileShelfKit.load(from: missing).isEmpty)
+
+ let corrupt = directory.appendingPathComponent("corrupt.json")
+ try Data("{ this is not json".utf8).write(to: corrupt)
+ XCTAssertTrue(FileShelfKit.load(from: corrupt).isEmpty)
+ }
+}
diff --git a/Tests/DMonteCoreTests/FileShelfSizingTests.swift b/Tests/DMonteCoreTests/FileShelfSizingTests.swift
new file mode 100644
index 0000000..a3d317f
--- /dev/null
+++ b/Tests/DMonteCoreTests/FileShelfSizingTests.swift
@@ -0,0 +1,44 @@
+import AppKit
+import XCTest
+@testable import DMonteCore
+
+/// The settings sheet is centred over the panel, so a sheet wider than the panel is clipped on
+/// both sides — the title loses its first and last characters and the switch column runs off the
+/// edge. File Shelf's sheet carries the longest explanatory paragraph of any tool here, so it is
+/// the one most likely to be resized later; these assertions are what stop that from clipping it.
+final class FileShelfSizingTests: XCTestCase {
+
+ func testSettingsSheetNeverExceedsThePanel() {
+ let panel = FileShelfSizing.preferredSize()
+ let settings = FileShelfSizing.settingsSize()
+
+ XCTAssertLessThanOrEqual(
+ settings.width, panel.width,
+ "A settings sheet wider than the panel is clipped on both sides"
+ )
+ XCTAssertLessThanOrEqual(
+ settings.height, panel.height,
+ "A settings sheet taller than the panel cannot show its bottom row — on this sheet, Quit"
+ )
+ }
+
+ /// The sheet should be as large as it was designed to be, shrinking only as far as the panel
+ /// forces. Asserting the contract rather than restating the arithmetic: when this rule changed
+ /// from "scale with the panel" to "cap at the panel", the tests that restated the formula
+ /// failed while the ones asserting the relationship kept passing.
+ func testSettingsUsesItsDesignSizeUnlessThePanelIsSmaller() {
+ let panel = FileShelfSizing.preferredSize()
+ let settings = FileShelfSizing.settingsSize()
+
+ XCTAssertEqual(settings.width, min(300, panel.width - 36), accuracy: 1)
+ XCTAssertEqual(settings.height, min(300, panel.height - 36), accuracy: 1)
+ }
+
+ /// The scale is derived from live screen and menu-bar metrics, so the guarantee has to hold
+ /// across the whole range rather than at whatever this machine reports today.
+ func testScaleStaysWithinItsDocumentedBounds() {
+ let scale = FileShelfSizing.currentScale
+ XCTAssertGreaterThanOrEqual(scale, 0.82)
+ XCTAssertLessThanOrEqual(scale, 1.0)
+ }
+}
diff --git a/Tests/DMonteCoreTests/FileShelfStoreTests.swift b/Tests/DMonteCoreTests/FileShelfStoreTests.swift
new file mode 100644
index 0000000..d445ca5
--- /dev/null
+++ b/Tests/DMonteCoreTests/FileShelfStoreTests.swift
@@ -0,0 +1,317 @@
+import Foundation
+import XCTest
+@testable import DMonteCore
+
+/// Drives the real `FileShelfStore` — the same code the popover talks to — against a temporary
+/// directory and its own `UserDefaults` suite, so neither the user's real shelf nor their real
+/// preferences are ever touched.
+///
+/// The store is where "shelving does not copy bytes" and "the shelf survives a quit" actually have
+/// to be true, so both are asserted here rather than only at the pure-logic layer.
+@MainActor
+final class FileShelfStoreTests: XCTestCase {
+ private var directory: URL!
+ private var filesDirectory: URL!
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("FileShelfStoreTests-\(UUID().uuidString)", isDirectory: true)
+ directory = root.appendingPathComponent("shelf", isDirectory: true)
+ filesDirectory = root.appendingPathComponent("files", isDirectory: true)
+ try FileManager.default.createDirectory(at: filesDirectory, withIntermediateDirectories: true)
+
+ suiteName = "com.havokentity.mactools.tests.fileshelf.\(UUID().uuidString)"
+ defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ }
+
+ override func tearDownWithError() throws {
+ if let directory {
+ try? FileManager.default.removeItem(at: directory.deletingLastPathComponent())
+ }
+ if let suiteName {
+ defaults?.removePersistentDomain(forName: suiteName)
+ }
+ directory = nil
+ filesDirectory = nil
+ defaults = nil
+ suiteName = nil
+ try super.tearDownWithError()
+ }
+
+ private func makeStore() -> FileShelfStore {
+ FileShelfStore(directory: directory, defaults: defaults)
+ }
+
+ @discardableResult
+ private func makeFile(named name: String, contents: String = "payload") throws -> URL {
+ let url = filesDirectory.appendingPathComponent(name)
+ try Data(contents.utf8).write(to: url)
+ return url
+ }
+
+ /// Rewrites `url` the way a Mac app saves it: the new contents are written alongside and then
+ /// swapped into place. This is what TextEdit, Preview, Xcode and anything built on NSDocument
+ /// do, and the reason it matters here is that the swap gives the file a brand-new inode while
+ /// leaving its path and its bookmark alone.
+ private func safeSave(_ url: URL, contents: String) throws {
+ let staging = url.deletingLastPathComponent()
+ .appendingPathComponent("safe-save-\(UUID().uuidString)")
+ try Data(contents.utf8).write(to: staging)
+ _ = try FileManager.default.replaceItemAt(url, withItemAt: staging)
+ }
+
+ // MARK: - Shelving
+
+ func testShelvingAFileAddsOneRow() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "report.pdf")
+
+ XCTAssertEqual(store.shelve(urls: [url]), 1)
+
+ XCTAssertEqual(store.items.count, 1)
+ XCTAssertEqual(store.items[0].displayName, "report.pdf")
+ XCTAssertTrue(store.items[0].isAvailable)
+ }
+
+ /// The single most important thing this tool must not do. A shelf that copied bytes would
+ /// silently double the disk usage of every large file someone parked on it.
+ func testShelvingDoesNotCopyTheFileAnywhere() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "big.bin", contents: String(repeating: "x", count: 4096))
+
+ store.shelve(urls: [url])
+
+ let shelfContents = (try? FileManager.default.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: nil
+ )) ?? []
+
+ XCTAssertEqual(
+ shelfContents.map(\.lastPathComponent), ["shelf.json"],
+ "The shelf directory must hold nothing but its index — never a copy of a shelved file"
+ )
+ // And the entry still points at the original, not at some duplicate.
+ XCTAssertEqual(
+ store.items[0].url?.resolvingSymlinksInPath().path,
+ url.resolvingSymlinksInPath().path
+ )
+ }
+
+ func testShelvingTheSameFileTwiceStillLeavesOneRow() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "duplicate.txt")
+
+ XCTAssertEqual(store.shelve(urls: [url]), 1)
+ XCTAssertEqual(store.shelve(urls: [url]), 0, "A re-drop adds no rows, so it must not report one")
+
+ XCTAssertEqual(store.items.count, 1)
+ XCTAssertNotNil(store.statusMessage, "A drop that changed nothing has to say so, or it reads as ignored")
+ }
+
+ /// The same re-drop, but with the file saved in between — which is the case that actually
+ /// happens, since the reason to shelve something is usually that you are still working on it.
+ ///
+ /// A safe save replaces the file with a fresh inode, so the identity cached on the shelf row
+ /// stops matching the file the row's bookmark still points at. Dropping straight onto the
+ /// menu-bar icon never opens the popover, so nothing has re-resolved the shelf since it was
+ /// last looked at; if `shelve` trusted those cached identities it would find no match and add
+ /// a second permanent row for a file that is already there.
+ func testRedroppingAFileThatWasSavedInTheMeantimeStillLeavesOneRow() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "report.txt")
+ XCTAssertEqual(store.shelve(urls: [url]), 1)
+ let identityWhenShelved = try XCTUnwrap(store.items[0].identity)
+
+ try safeSave(url, contents: "edited in some app")
+ XCTAssertNotEqual(
+ FileShelfKit.identity(for: url), identityWhenShelved,
+ "This test is only meaningful if the safe save really did move the file to a new inode"
+ )
+
+ // Deliberately no refresh(): a drop onto the menu-bar icon never opens the popover.
+ XCTAssertEqual(store.shelve(urls: [url]), 0, "The file was already shelved, so no row was gained")
+
+ XCTAssertEqual(store.items.count, 1, "An edited file must collapse onto its existing row, not fork off a new one")
+ XCTAssertEqual(store.items[0].displayName, "report.txt")
+ XCTAssertNotNil(store.statusMessage, "The user has to be told the drop landed on something already shelved")
+ }
+
+ /// The other half of the same problem: a file that moved while the popover was closed is still
+ /// the same file, and re-dropping it must not leave two rows behind either.
+ func testRedroppingAFileThatWasRenamedInTheMeantimeStillLeavesOneRow() throws {
+ let store = makeStore()
+ let original = try makeFile(named: "before.txt")
+ XCTAssertEqual(store.shelve(urls: [original]), 1)
+
+ let renamed = filesDirectory.appendingPathComponent("after.txt")
+ try FileManager.default.moveItem(at: original, to: renamed)
+
+ XCTAssertEqual(store.shelve(urls: [renamed]), 0)
+
+ XCTAssertEqual(store.items.count, 1)
+ XCTAssertEqual(store.items[0].displayName, "after.txt")
+ }
+
+ func testShelvingSeveralFilesKeepsTheDroppedOrder() throws {
+ let store = makeStore()
+ let first = try makeFile(named: "a.txt")
+ let second = try makeFile(named: "b.txt")
+ let third = try makeFile(named: "c.txt")
+
+ XCTAssertEqual(store.shelve(urls: [first, second, third]), 3)
+
+ XCTAssertEqual(store.items.map(\.displayName), ["a.txt", "b.txt", "c.txt"])
+ }
+
+ // MARK: - Availability
+
+ /// The rule the feature turns on: an entry whose file is gone is shown as unavailable, never
+ /// quietly dropped.
+ func testAnEntryWhoseFileIsDeletedStaysOnTheShelfAsUnavailable() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "temporary.txt")
+ store.shelve(urls: [url])
+
+ try FileManager.default.removeItem(at: url)
+ store.refresh()
+
+ XCTAssertEqual(store.items.count, 1, "A deleted file must not make its shelf row disappear")
+ XCTAssertFalse(store.items[0].isAvailable)
+ XCTAssertEqual(store.items[0].displayName, "temporary.txt")
+ }
+
+ /// Reveal has nothing to reveal for an unavailable row, so it reports that instead of opening
+ /// a Finder window on a path that is not there.
+ func testRevealingAnUnavailableEntryReportsItRatherThanOpeningFinder() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "vanishing.txt")
+ store.shelve(urls: [url])
+ try FileManager.default.removeItem(at: url)
+ store.refresh()
+
+ store.revealInFinder(id: store.items[0].id)
+
+ XCTAssertNotNil(store.statusMessage)
+ }
+
+ // MARK: - Removal
+
+ func testRemovingTakesTheRowButLeavesTheFileAlone() throws {
+ let store = makeStore()
+ let url = try makeFile(named: "keep-me.txt")
+ store.shelve(urls: [url])
+
+ store.remove(id: store.items[0].id)
+
+ XCTAssertTrue(store.items.isEmpty)
+ XCTAssertTrue(
+ FileManager.default.fileExists(atPath: url.path),
+ "Taking a file off the shelf must never delete it"
+ )
+ }
+
+ /// Clearing is the one action here with nothing to undo, so with confirmation on the first
+ /// press only arms it.
+ func testClearAllNeedsASecondPressWhenConfirmationIsOn() throws {
+ let store = makeStore()
+ store.setConfirmsClearAll(true)
+ store.shelve(urls: [try makeFile(named: "one.txt"), try makeFile(named: "two.txt")])
+
+ store.requestClearAll()
+
+ XCTAssertTrue(store.isConfirmingClearAll)
+ XCTAssertEqual(store.items.count, 2, "The arming press must not have cleared anything yet")
+
+ store.confirmClearAll()
+
+ XCTAssertFalse(store.isConfirmingClearAll)
+ XCTAssertTrue(store.items.isEmpty)
+ }
+
+ func testClearAllIsImmediateWhenConfirmationIsOff() throws {
+ let store = makeStore()
+ store.setConfirmsClearAll(false)
+ store.shelve(urls: [try makeFile(named: "one.txt")])
+
+ store.requestClearAll()
+
+ XCTAssertTrue(store.items.isEmpty)
+ XCTAssertFalse(store.isConfirmingClearAll)
+ }
+
+ /// An armed clear authorises the list the user was looking at. Removing a row changes that
+ /// list, so the arming has to be dropped rather than carried onto a different shelf.
+ func testRemovingARowDisarmsAPendingClear() throws {
+ let store = makeStore()
+ store.setConfirmsClearAll(true)
+ store.shelve(urls: [try makeFile(named: "one.txt"), try makeFile(named: "two.txt")])
+ store.requestClearAll()
+ XCTAssertTrue(store.isConfirmingClearAll)
+
+ store.remove(id: store.items[0].id)
+
+ XCTAssertFalse(store.isConfirmingClearAll)
+ XCTAssertEqual(store.items.count, 1)
+ }
+
+ /// Turning confirmation off must not fire the press that is already pending.
+ func testTurningConfirmationOffDisarmsAPendingClear() throws {
+ let store = makeStore()
+ store.setConfirmsClearAll(true)
+ store.shelve(urls: [try makeFile(named: "one.txt")])
+ store.requestClearAll()
+ XCTAssertTrue(store.isConfirmingClearAll)
+
+ store.setConfirmsClearAll(false)
+
+ XCTAssertFalse(store.isConfirmingClearAll)
+ XCTAssertEqual(store.items.count, 1, "Changing a setting must never be a destructive action")
+ }
+
+ // MARK: - Persistence
+
+ /// A second store over the same directory is what a relaunch looks like.
+ func testTheShelfSurvivesARelaunch() throws {
+ let first = try makeFile(named: "one.txt")
+ let second = try makeFile(named: "two.txt")
+
+ let original = makeStore()
+ original.shelve(urls: [first, second])
+ XCTAssertEqual(original.items.count, 2)
+
+ let relaunched = makeStore()
+
+ XCTAssertEqual(relaunched.items.map(\.displayName), ["one.txt", "two.txt"])
+ XCTAssertTrue(relaunched.items.allSatisfy(\.isAvailable))
+ }
+
+ /// …and it survives one while the file is being renamed underneath it, which is the whole
+ /// reason a bookmark is stored rather than a path.
+ func testARelaunchFindsAFileThatWasRenamedInTheMeantime() throws {
+ let original = try makeFile(named: "before.txt")
+
+ let store = makeStore()
+ store.shelve(urls: [original])
+
+ let renamed = filesDirectory.appendingPathComponent("after.txt")
+ try FileManager.default.moveItem(at: original, to: renamed)
+
+ let relaunched = makeStore()
+
+ XCTAssertEqual(relaunched.items.count, 1)
+ XCTAssertTrue(relaunched.items[0].isAvailable)
+ XCTAssertEqual(relaunched.items[0].displayName, "after.txt")
+ }
+
+ func testAnEmptiedShelfStaysEmptyAcrossARelaunch() throws {
+ let store = makeStore()
+ store.setConfirmsClearAll(false)
+ store.shelve(urls: [try makeFile(named: "one.txt")])
+ store.requestClearAll()
+
+ XCTAssertTrue(makeStore().items.isEmpty)
+ }
+}
diff --git a/Tests/DMonteCoreTests/StatusItemFileDropViewTests.swift b/Tests/DMonteCoreTests/StatusItemFileDropViewTests.swift
new file mode 100644
index 0000000..a03db3e
--- /dev/null
+++ b/Tests/DMonteCoreTests/StatusItemFileDropViewTests.swift
@@ -0,0 +1,87 @@
+import AppKit
+import XCTest
+@testable import DMonteCore
+
+/// Guards the click path through the transparent drop overlay that covers the File Shelf's
+/// menu-bar button.
+///
+/// The overlay exists only so the button can accept a file drag, and it must be invisible to
+/// everything else. That has two halves — the view has to agree to receive a click in the first
+/// place, and it has to hand the click straight back to the button — and losing either one leaves
+/// the tool with an icon that cannot be opened at all, which no shelf-logic test would catch.
+@MainActor
+final class StatusItemFileDropViewTests: XCTestCase {
+ /// Stands in for the `NSStatusBarButton` underneath, since AppKit will not let us make a real
+ /// one outside a status item.
+ private final class RecordingButton: NSView {
+ var mouseDownCount = 0
+ var rightMouseDownCount = 0
+ var mouseUpCount = 0
+ var rightMouseUpCount = 0
+
+ override func mouseDown(with event: NSEvent) { mouseDownCount += 1 }
+ override func rightMouseDown(with event: NSEvent) { rightMouseDownCount += 1 }
+ override func mouseUp(with event: NSEvent) { mouseUpCount += 1 }
+ override func rightMouseUp(with event: NSEvent) { rightMouseUpCount += 1 }
+ }
+
+ private func makeEvent(type: NSEvent.EventType) throws -> NSEvent {
+ try XCTUnwrap(NSEvent.mouseEvent(
+ with: type,
+ location: NSPoint(x: 10, y: 10),
+ modifierFlags: [],
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: 0,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1
+ ))
+ }
+
+ /// The regression this file exists for.
+ ///
+ /// The shelf runs as an `LSUIElement` helper that never becomes the active app, so *every*
+ /// click on its menu-bar icon is a first mouse click. The overlay wins hit-testing over the
+ /// button it covers, and plain `NSView` answers `false` here where `NSStatusBarButton` answers
+ /// `true` — so with the default in place AppKit spends the mouse-down activating the app
+ /// instead of delivering it, `mouseDown(with:)` is never called, and the popover never opens.
+ func testTheOverlayAcceptsTheFirstClickTheWayTheButtonUnderneathWould() throws {
+ let dropView = StatusItemFileDropView(frame: NSRect(x: 0, y: 0, width: 24, height: 22))
+
+ XCTAssertTrue(
+ dropView.acceptsFirstMouse(for: try makeEvent(type: .leftMouseDown)),
+ "A menu-bar helper is never the active app, so refusing the first mouse means refusing every click"
+ )
+ XCTAssertTrue(
+ dropView.acceptsFirstMouse(for: nil),
+ "AppKit is allowed to ask with no event at all, and the answer must not change"
+ )
+ }
+
+ /// Accepting the click is only useful if it then reaches the button, which is what actually
+ /// toggles the popover.
+ func testEveryMouseEventIsHandedBackToTheButtonUnderneath() throws {
+ let button = RecordingButton(frame: NSRect(x: 0, y: 0, width: 24, height: 22))
+ let dropView = StatusItemFileDropView(frame: button.bounds)
+ button.addSubview(dropView)
+
+ dropView.mouseDown(with: try makeEvent(type: .leftMouseDown))
+ dropView.mouseUp(with: try makeEvent(type: .leftMouseUp))
+ dropView.rightMouseDown(with: try makeEvent(type: .rightMouseDown))
+ dropView.rightMouseUp(with: try makeEvent(type: .rightMouseUp))
+
+ XCTAssertEqual(button.mouseDownCount, 1)
+ XCTAssertEqual(button.mouseUpCount, 1)
+ XCTAssertEqual(button.rightMouseDownCount, 1, "Right-click drives the menu, so it has to pass through too")
+ XCTAssertEqual(button.rightMouseUpCount, 1)
+ }
+
+ /// The overlay is only worth its risk because it is a drag destination; if it stopped
+ /// advertising file URLs the drop would never arrive and the pass-through would be pure cost.
+ func testTheOverlayIsRegisteredForFileDrags() {
+ let dropView = StatusItemFileDropView(frame: NSRect(x: 0, y: 0, width: 24, height: 22))
+
+ XCTAssertTrue(dropView.registeredDraggedTypes.contains(.fileURL))
+ }
+}