From 23b557920ac32d8df7f13201b21860bba2ceff27 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:26:49 +0530 Subject: [PATCH 1/2] Add File Shelf, a drag-and-drop staging area for files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a file between two apps, or between two Spaces, currently means keeping a Finder window parked somewhere just to hold it. File Shelf is that holding area without the window: drop files onto the menu bar icon or into the popover, then drag them straight back out wherever you land. Shelving deliberately does not copy bytes. Each entry is a security-scoped bookmark to the original file, which buys three things a list of paths could not: 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 — exactly the tidying people do while something is parked on its way elsewhere. Dragging out hands over the real file URL, so the receiving app attaches or opens the original rather than a duplicate. A file that has been deleted, or that lives on a disk that is currently unplugged, stays on the shelf and is drawn as unavailable. The alternative — pruning it — makes the shelf look like it lost the thing the user parked, and an unplugged disk comes back on its own. De-duplication compares device and inode rather than paths, so the same file reached through a symlink, or dropped again after a rename, collapses onto the row it already has. Bookmarks are resolved with `.withoutUI` and `.withoutMounting`: without them, a routine refresh of an entry on an unplugged disk pops a system "please insert the disk" panel out of a background menu-bar app. The menu-bar icon is a drop target in its own right, which is the point of the tool — park a file without opening anything. AppKit creates NSStatusBarButton and its dragging-destination methods are overrides rather than delegate callbacks, so the only way in is a transparent child view that registers for file URLs and hands every mouse event straight back to the button underneath; without that pass-through the overlay would be a dead zone over the icon. The rules that decide what the shelf holds — ordering, de-duplication, the cap, bookmark resolution and the on-disk format — live in FileShelfKit as pure functions over values, and the tests drive real bookmarks against files in a temporary directory. A stubbed resolver would have asserted nothing about the one question that matters: whether a bookmark actually follows a rename. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 + Package.swift | 11 + Packaging/FileShelfInfo.plist | 32 ++ README.md | 1 + Scripts/package_app.sh | 1 + Sources/DMonteCore/AppPreferences.swift | 3 +- Sources/DMonteCore/FileShelfKit.swift | 375 +++++++++++++ Sources/DMonteCore/FileShelfSizing.swift | 35 ++ Sources/DMonteCore/FileShelfStore.swift | 243 +++++++++ Sources/DMonteCore/FileShelfView.swift | 497 ++++++++++++++++++ .../DMonteCore/StatusBarButtonContent.swift | 25 + .../DMonteCore/StatusItemFileDropView.swift | 104 ++++ Sources/DMonteCore/ToolboxCatalog.swift | 3 +- .../FileShelfAppDelegate.swift | 146 +++++ Sources/DMonteFileShelfApp/main.swift | 24 + Tests/DMonteCoreTests/FileShelfKitTests.swift | 347 ++++++++++++ .../FileShelfSizingTests.swift | 44 ++ .../DMonteCoreTests/FileShelfStoreTests.swift | 262 +++++++++ 18 files changed, 2168 insertions(+), 2 deletions(-) create mode 100644 Packaging/FileShelfInfo.plist create mode 100644 Sources/DMonteCore/FileShelfKit.swift create mode 100644 Sources/DMonteCore/FileShelfSizing.swift create mode 100644 Sources/DMonteCore/FileShelfStore.swift create mode 100644 Sources/DMonteCore/FileShelfView.swift create mode 100644 Sources/DMonteCore/StatusItemFileDropView.swift create mode 100644 Sources/DMonteFileShelfApp/FileShelfAppDelegate.swift create mode 100644 Sources/DMonteFileShelfApp/main.swift create mode 100644 Tests/DMonteCoreTests/FileShelfKitTests.swift create mode 100644 Tests/DMonteCoreTests/FileShelfSizingTests.swift create mode 100644 Tests/DMonteCoreTests/FileShelfStoreTests.swift 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..7243188 --- /dev/null +++ b/Sources/DMonteCore/FileShelfKit.swift @@ -0,0 +1,375 @@ +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 resolved, but nothing is there any more: deleted, or on a volume that is not + /// mounted right now. Plugging the disk back in makes this entry work again, which is exactly + /// why it is kept rather than pruned. + case missing + + /// The bookmark itself no longer decodes to a location at all. 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 + ) + } + + /// 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. A bookmark that resolves to nothing yields `.missing` rather + /// than `.unresolvable`: the difference is "the disk is not here" 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 { + return ShelfItem(file: file, url: nil, availability: .unresolvable, 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..d5fcdc0 --- /dev/null +++ b/Sources/DMonteCore/FileShelfStore.swift @@ -0,0 +1,243 @@ +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 } + + // 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(items.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: items), 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(items.map { FileShelfKit.resolveItem($0.file) }, 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 + + /// 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..0e9ed58 --- /dev/null +++ b/Sources/DMonteCore/FileShelfView.swift @@ -0,0 +1,497 @@ +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: + return "Not where it was — the disk may be unplugged" + case .unresolvable: + return "Can’t be found any more" + } + } + + 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..102ce0c --- /dev/null +++ b/Sources/DMonteCore/StatusItemFileDropView.swift @@ -0,0 +1,104 @@ +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. + + 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..4a48f70 --- /dev/null +++ b/Tests/DMonteCoreTests/FileShelfKitTests.swift @@ -0,0 +1,347 @@ +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") + } + + // 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..dd64469 --- /dev/null +++ b/Tests/DMonteCoreTests/FileShelfStoreTests.swift @@ -0,0 +1,262 @@ +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 + } + + // 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") + } + + 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) + } +} From 1850efec99aa47bfb889691f64a38b7c5d9dd2b5 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 23:10:33 +0530 Subject: [PATCH 2/2] File Shelf: make the icon clickable and stop re-drops forking a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the drop overlay and the shelf logic were getting wrong. The overlay that turns the status button into a drag target wins hit-testing over the button, and NSView answers false to acceptsFirstMouse where NSStatusBarButton answers true. This tool is LSUIElement and never becomes the active app, so every click on its icon is a first-mouse click: AppKit was free to spend that click on activation instead of delivering it, and the mouseDown pass-through that opens the popover never ran. The only other two status-button subviews in this package already override this for exactly the same reason. De-duplication compared a dropped file against identities cached on `items`, which were computed at the last resolve — startup, or the last popover open. A device/inode pair does not survive a safe save, and a drop straight onto the menu-bar icon never opens the popover, so shelving a file, editing it in any app that writes atomically, and dropping it again found no match and minted a second row. Nothing collapsed the two afterwards. shelve() now re-resolves first, through the same helper refresh() uses, so a drop is judged against the shelf as it is rather than as it was. Finally, .missing was unreachable. A bookmark to a deleted file, or to one on an unmounted volume, does not resolve at all — .withoutMounting sees to the second — so every failure landed on .unresolvable, whose copy tells the user the entry has to be shelved again. Someone who merely unplugged an external disk would have removed a row that was about to start working by itself. The two cases are now told apart by reading the bookmark's own cached resource values, which survive while the file does not; junk data carries none. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/FileShelfKit.swift | 37 ++++++-- Sources/DMonteCore/FileShelfStore.swift | 26 +++++- Sources/DMonteCore/FileShelfView.swift | 7 +- .../DMonteCore/StatusItemFileDropView.swift | 13 +++ Tests/DMonteCoreTests/FileShelfKitTests.swift | 40 +++++++++ .../DMonteCoreTests/FileShelfStoreTests.swift | 55 ++++++++++++ .../StatusItemFileDropViewTests.swift | 87 +++++++++++++++++++ 7 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 Tests/DMonteCoreTests/StatusItemFileDropViewTests.swift diff --git a/Sources/DMonteCore/FileShelfKit.swift b/Sources/DMonteCore/FileShelfKit.swift index 7243188..c5ceefb 100644 --- a/Sources/DMonteCore/FileShelfKit.swift +++ b/Sources/DMonteCore/FileShelfKit.swift @@ -9,13 +9,14 @@ public enum ShelfAvailability: String, Codable, Sendable, Equatable { /// The bookmark resolved and something is at the other end. case available - /// The bookmark resolved, but nothing is there any more: deleted, or on a volume that is not - /// mounted right now. Plugging the disk back in makes this entry work again, which is exactly - /// why it is kept rather than pruned. + /// 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 bookmark itself no longer decodes to a location at all. Recoverable only by shelving - /// the file again, but still worth showing so the user can see what they have lost. + /// 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 } @@ -248,15 +249,33 @@ public enum FileShelfKit { ) } + /// 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. A bookmark that resolves to nothing yields `.missing` rather - /// than `.unresolvable`: the difference is "the disk is not here" versus "this data is junk", - /// and only the first one comes back on its own. + /// 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 { - return ShelfItem(file: file, url: nil, availability: .unresolvable, identity: nil) + // 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 diff --git a/Sources/DMonteCore/FileShelfStore.swift b/Sources/DMonteCore/FileShelfStore.swift index d5fcdc0..6a5a0ed 100644 --- a/Sources/DMonteCore/FileShelfStore.swift +++ b/Sources/DMonteCore/FileShelfStore.swift @@ -75,10 +75,21 @@ public final class FileShelfStore: ObservableObject { 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(items.compactMap(\.identity)) + 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. @@ -90,7 +101,7 @@ public final class FileShelfStore: ObservableObject { } } - adopt(FileShelfKit.adding(candidates, to: items), persist: true) + 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. @@ -145,7 +156,7 @@ public final class FileShelfStore: ObservableObject { /// 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(items.map { FileShelfKit.resolveItem($0.file) }, persist: true) + adopt(resolvedItems(), persist: true) } /// Shows the file in Finder. An entry that no longer resolves says so instead of opening a @@ -196,6 +207,15 @@ public final class FileShelfStore: ObservableObject { // 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. /// diff --git a/Sources/DMonteCore/FileShelfView.swift b/Sources/DMonteCore/FileShelfView.swift index 0e9ed58..363bf6f 100644 --- a/Sources/DMonteCore/FileShelfView.swift +++ b/Sources/DMonteCore/FileShelfView.swift @@ -208,9 +208,12 @@ public struct FileShelfPopoverView: View { // line above, and repeating it wastes the width that would show where it lives. return item.url?.deletingLastPathComponent().path ?? item.displayPath case .missing: - return "Not where it was — the disk may be unplugged" + // 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: - return "Can’t be found any more" + // 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" } } diff --git a/Sources/DMonteCore/StatusItemFileDropView.swift b/Sources/DMonteCore/StatusItemFileDropView.swift index 102ce0c..727ca83 100644 --- a/Sources/DMonteCore/StatusItemFileDropView.swift +++ b/Sources/DMonteCore/StatusItemFileDropView.swift @@ -44,6 +44,19 @@ public final class StatusItemFileDropView: NSView { // 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) } diff --git a/Tests/DMonteCoreTests/FileShelfKitTests.swift b/Tests/DMonteCoreTests/FileShelfKitTests.swift index 4a48f70..fab80a3 100644 --- a/Tests/DMonteCoreTests/FileShelfKitTests.swift +++ b/Tests/DMonteCoreTests/FileShelfKitTests.swift @@ -209,6 +209,46 @@ final class FileShelfKitTests: XCTestCase { 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 diff --git a/Tests/DMonteCoreTests/FileShelfStoreTests.swift b/Tests/DMonteCoreTests/FileShelfStoreTests.swift index dd64469..d445ca5 100644 --- a/Tests/DMonteCoreTests/FileShelfStoreTests.swift +++ b/Tests/DMonteCoreTests/FileShelfStoreTests.swift @@ -52,6 +52,17 @@ final class FileShelfStoreTests: XCTestCase { 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 { @@ -100,6 +111,50 @@ final class FileShelfStoreTests: XCTestCase { 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") 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)) + } +}