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