diff --git a/Semper/Shelf/ShelfFileSelection.swift b/Semper/Shelf/ShelfFileSelection.swift new file mode 100644 index 0000000..7990d07 --- /dev/null +++ b/Semper/Shelf/ShelfFileSelection.swift @@ -0,0 +1,35 @@ +import AppKit + +@MainActor +protocol ShelfFileChoosing: AnyObject { + func chooseFiles() async -> [URL]? + func cancel() +} + +@MainActor +final class NativeShelfFileChooser: ShelfFileChoosing { + private var panel: NSOpenPanel? + + func chooseFiles() async -> [URL]? { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.resolvesAliases = true + panel.prompt = "Add to Shelf" + panel.message = "Choose files or folders. Original items stay in place." + self.panel = panel + return await withCheckedContinuation { continuation in + panel.begin { response in + Task { @MainActor in + self.panel = nil + continuation.resume(returning: response == .OK ? panel.urls : nil) + } + } + } + } + + func cancel() { + panel?.cancel(nil) + } +} diff --git a/Semper/Shelf/ShelfModels.swift b/Semper/Shelf/ShelfModels.swift index d3c8713..602f46e 100644 --- a/Semper/Shelf/ShelfModels.swift +++ b/Semper/Shelf/ShelfModels.swift @@ -57,9 +57,9 @@ nonisolated enum ShelfFileState: Equatable, Sendable { var message: String { switch self { case .available(let directory): directory ? "Folder reference" : "File reference" - case .missing: "Original file is missing. Locate it in Finder and drop it again." + case .missing: "Original file is missing. Locate it in Finder and add it again." case .cloudOnly: "Download this item in Finder, then refresh the shelf." - case .inaccessible: "File access is unavailable. Drop the item again to grant access." + case .inaccessible: "File access is unavailable. Choose or drop the item again to grant access." } } var isAvailable: Bool { @@ -79,7 +79,7 @@ nonisolated enum ShelfFailure: Error, Equatable, LocalizedError, Sendable { case .unsupported: "This drop has no supported file, image, link, or plain-text representation." case .missing: "The original file is missing." case .cloudOnly: "Download this item in Finder before using it." - case .inaccessible: "The item cannot be read. Check access in Finder and drop it again." + case .inaccessible: "The item cannot be read. Check access in Finder, then choose or drop it again." case .invalidStore: "Saved shelf data could not be read. It has been left untouched." case .storeVersion: "This saved shelf uses a newer format. It has been left untouched." case .storeWrite: "The shelf could not save its local data." diff --git a/Semper/Shelf/ShelfService.swift b/Semper/Shelf/ShelfService.swift index 5ecf1a4..af98d7f 100644 --- a/Semper/Shelf/ShelfService.swift +++ b/Semper/Shelf/ShelfService.swift @@ -20,6 +20,7 @@ final class ShelfService { private(set) var persistenceEnabled = false private(set) var defaultExpiry: ShelfExpiry = .quit private(set) var importCount = 0 + private(set) var isChoosingFiles = false private(set) var message: String? private(set) var storeNeedsReset = false private var pendingImportCleanup: Set = [] @@ -27,6 +28,11 @@ final class ShelfService { private var importCancellationCount = 0 var canClear: Bool { !items.isEmpty || !pendingImportCleanup.isEmpty || importCleanupNeedsRetry } + var canChooseFiles: Bool { + isRunning && !isStopping && !isClearing && !storeNeedsReset && !isChoosingFiles + && importCount == 0 && importCancellationCount == 0 && removingIDs.isEmpty + && pendingImportCleanup.isEmpty && !importCleanupNeedsRetry && items.count < ShelfLimits.items + } let store: ShelfStore @ObservationIgnored private let access: any ShelfFileAccess @@ -39,7 +45,8 @@ final class ShelfService { @ObservationIgnored private var generation = 0 @ObservationIgnored private var clearGeneration = 0 @ObservationIgnored private var clearTask: Task, Never>? - @ObservationIgnored private var removingIDs: Set = [] + private var removingIDs: Set = [] + @ObservationIgnored private let fileChooser: any ShelfFileChoosing @ObservationIgnored private let importer: @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload @ObservationIgnored private var stopTask: Task? @@ -48,12 +55,14 @@ final class ShelfService { init( store: ShelfStore = .standard, access: any ShelfFileAccess = NativeShelfFileAccess(), now: @escaping @Sendable () -> Date = { Date() }, + fileChooser: any ShelfFileChoosing = NativeShelfFileChooser(), importer: @escaping @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload = ShelfDropImporter.load ) { self.store = store self.access = access self.now = now + self.fileChooser = fileChooser self.importer = importer } @@ -346,7 +355,48 @@ final class ShelfService { checksums[id] = .cancelled } - func importDrops(_ providers: [NSItemProvider]) -> Bool { + @discardableResult + func chooseFiles() -> Bool { + guard removingIDs.isEmpty else { + message = "Wait for the item removal to finish before choosing files." + return false + } + guard admitImport(count: 1) else { return false } + let currentGeneration = generation + let id = UUID() + let task = Task { [weak self] in + guard let self else { return } + defer { + self.importTasks[id] = nil + self.importCount = 0 + self.isChoosingFiles = false + } + guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return } + guard let urls = await self.fileChooser.chooseFiles() else { return } + guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return } + guard self.removingIDs.isEmpty else { + self.message = "Wait for the item removal to finish before choosing files." + return + } + guard urls.count <= ShelfLimits.items - self.items.count else { + self.report(ShelfFailure.full) + return + } + self.importCount = urls.count + self.isChoosingFiles = false + for url in urls { + guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return } + do { try self.acceptImported(.file(url)) } catch { self.report(error) } + self.importCount = max(0, self.importCount - 1) + if self.importCount > 0 { await Task.yield() } + } + } + importTasks[id] = task + isChoosingFiles = true + return true + } + + private func admitImport(count: Int) -> Bool { guard isRunning, !isClearing, !isStopping, !storeNeedsReset else { report(ShelfFailure.stopped) return false @@ -359,15 +409,20 @@ final class ShelfService { message = "Clear Shelf to retry temporary image cleanup before adding another drop." return false } - guard providers.count <= ShelfLimits.items - items.count - importTasks.count else { + guard count <= ShelfLimits.items - items.count - importTasks.count else { report(ShelfFailure.full) return false } - let currentGeneration = generation - guard importTasks.isEmpty else { - message = "Wait for the current drop or cancel it before adding another." + guard importTasks.isEmpty, !isChoosingFiles else { + message = "Finish or cancel the current selection or import before adding more items." return false } + return true + } + + func importDrops(_ providers: [NSItemProvider]) -> Bool { + guard admitImport(count: providers.count) else { return false } + let currentGeneration = generation let id = UUID() let task = Task { [weak self] in guard let self else { return } @@ -406,6 +461,7 @@ final class ShelfService { } let workers = importTasks for task in workers.values { task.cancel() } + if isChoosingFiles { fileChooser.cancel() } for (id, task) in workers { await task.value importTasks[id] = nil diff --git a/Semper/Shelf/ShelfViews.swift b/Semper/Shelf/ShelfViews.swift index 84a2d3f..8b9a60e 100644 --- a/Semper/Shelf/ShelfViews.swift +++ b/Semper/Shelf/ShelfViews.swift @@ -14,13 +14,16 @@ struct ShelfCompactView: View { .font(.headline) Spacer() Text("\(service.items.count)").foregroundStyle(.secondary) + Button("Choose Files…") { service.chooseFiles() } + .keyboardShortcut("o", modifiers: .command) + .disabled(!service.canChooseFiles) Button("Open", action: openDetail) } if !service.isRunning { Text("File Shelf is paused.").foregroundStyle(.secondary) Button("Start File Shelf") { service.start() } } else if service.items.isEmpty { - Text("Drop files, folders, links, images, or text here.") + Text("Choose files and folders, or drop files, links, images, or text here.") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 54) } else { @@ -69,10 +72,13 @@ struct ShelfDetailView: View { HStack { VStack(alignment: .leading, spacing: 4) { Text("File Shelf").font(.title2.weight(.semibold)) - Text("A temporary place for items you drop. Original files stay in place.") + Text("A temporary place for items you add. Original files stay in place.") .font(.callout).foregroundStyle(.secondary) } Spacer() + Button("Choose Files…", systemImage: "folder.badge.plus") { service.chooseFiles() } + .keyboardShortcut("o", modifiers: .command) + .disabled(!service.canChooseFiles) Button("Refresh", systemImage: "arrow.clockwise") { service.refresh() }.disabled(!service.isRunning) Button("Clear Shelf", systemImage: "tray") { confirmClear = true }.disabled(!service.canClear) } @@ -126,9 +132,9 @@ struct ShelfDetailView: View { Group { if service.items.isEmpty { ContentUnavailableView( - "Drop items here", systemImage: "tray.and.arrow.down", + "Add items to your shelf", systemImage: "tray.and.arrow.down", description: Text( - "Files and folders are held by reference. Drop text or images to keep a temporary local copy." + "Choose files and folders or drop items here. Files stay in place. Dropped text and images use a temporary local copy." )) } else { ScrollView { diff --git a/SemperTests/ShelfFileSelectionTests.swift b/SemperTests/ShelfFileSelectionTests.swift new file mode 100644 index 0000000..1e7a3e5 --- /dev/null +++ b/SemperTests/ShelfFileSelectionTests.swift @@ -0,0 +1,408 @@ +import AppKit +import Foundation +import Observation +import Testing + +@testable import Semper + +nonisolated private final class ShelfSelectionSignal: @unchecked Sendable { + private let lock = NSLock() + private var result: Bool? + private var continuation: CheckedContinuation? + var isResolved: Bool { lock.withLock { result != nil } } + + func resolve(_ value: Bool) { + let waiting: CheckedContinuation? = lock.withLock { + guard result == nil else { return nil } + result = value + defer { continuation = nil } + return continuation + } + waiting?.resume(returning: value) + } + + func wait() async -> Bool { + let received = await withTaskCancellationHandler { + await withCheckedContinuation { waiting in + let existing: Bool? = lock.withLock { + if let result { return result } + continuation = waiting + return nil + } + if let existing { waiting.resume(returning: existing) } + } + } onCancel: { + self.resolve(false) + } + return received && !Task.isCancelled + } +} + +@MainActor +private final class ShelfSelectionChooser: ShelfFileChoosing { + var selection: [URL]? + private(set) var calls = 0 + let entered = ShelfSelectionSignal() + let cancelled = ShelfSelectionSignal() + let release = ShelfSelectionSignal() + private var heldSelection: Task? + + func chooseFiles() async -> [URL]? { + let release = release + let heldSelection = Task.detached { await release.wait() } + self.heldSelection = heldSelection + calls += 1 + entered.resolve(true) + guard await heldSelection.value else { return nil } + return selection + } + + func cancel() { cancelled.resolve(true) } + + func finish() async { + release.resolve(true) + _ = await heldSelection?.value + } +} + +nonisolated private final class ShelfSelectionAccess: ShelfFileAccess, @unchecked Sendable { + private let lock = NSLock() + private var beginCount = 0 + private var endCount = 0 + private var bookmarkCount = 0 + private var resolveCount = 0 + let folder: URL + let overrideState: ShelfFileState? + let failBookmarks: Bool + var begins: Int { lock.withLock { beginCount } } + var balanced: Bool { lock.withLock { beginCount == endCount } } + var bookmarks: Int { lock.withLock { bookmarkCount } } + var resolutions: Int { lock.withLock { resolveCount } } + + init(folder: URL, state: ShelfFileState?, failBookmarks: Bool) { + self.folder = folder + overrideState = state + self.failBookmarks = failBookmarks + } + + func begin(_ url: URL) -> Bool { + lock.withLock { beginCount += 1 } + return true + } + func end(_ url: URL) { lock.withLock { endCount += 1 } } + func state(of url: URL) -> ShelfFileState { overrideState ?? .available(isDirectory: url == folder) } + func bookmark(for url: URL) throws -> Data { + lock.withLock { bookmarkCount += 1 } + if failBookmarks { throw ShelfFailure.inaccessible } + return Data(url.absoluteString.utf8) + } + func resolve(_ bookmark: Data) throws -> URL { + lock.withLock { resolveCount += 1 } + guard let string = String(data: bookmark, encoding: .utf8), let url = URL(string: string), url.isFileURL else { + throw ShelfFailure.inaccessible + } + return url + } +} + +@MainActor +private final class ShelfSelectionCompletion { + private let service: ShelfService + private let finished = ShelfSelectionSignal() + private var active = true + + init(_ service: ShelfService) { self.service = service } + + func wait() async -> Bool { + observe() + let result = await finished.wait() + active = false + return result + } + + private func observe() { + guard active else { return } + let complete = withObservationTracking { + let choosing = service.isChoosingFiles + let count = service.importCount + return !choosing && count == 0 + } onChange: { [weak self] in + Task { @MainActor [weak self] in self?.observe() } + } + if complete { finished.resolve(true) } + } +} + +@Suite("Shelf Choose Files", .timeLimit(.minutes(1))) +@MainActor +struct ShelfFileSelectionTests { + private struct Fixture { + let root: URL + let file: URL + let secondFile: URL + let folder: URL + let store: ShelfStore + let access: ShelfSelectionAccess + let chooser: ShelfSelectionChooser + let service: ShelfService + let dropEntered: ShelfSelectionSignal + let dropRelease: ShelfSelectionSignal + } + + private func withFixture( + state: ShelfFileState? = nil, failBookmarks: Bool = false, + body: (Fixture) async throws -> Void + ) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("shelf-selection-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) } + } + let file = root.appendingPathComponent("original.txt") + let secondFile = root.appendingPathComponent("second.txt") + let folder = root.appendingPathComponent("Folder", isDirectory: true) + try Data("original bytes".utf8).write(to: file) + try Data("second bytes".utf8).write(to: secondFile) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let store = ShelfStore(root: root.appendingPathComponent("store", isDirectory: true)) + let chooser = ShelfSelectionChooser() + let access = ShelfSelectionAccess(folder: folder, state: state, failBookmarks: failBookmarks) + let dropEntered = ShelfSelectionSignal() + let dropRelease = ShelfSelectionSignal() + let service = ShelfService( + store: store, access: access, fileChooser: chooser, + importer: { _, _ in + dropEntered.resolve(true) + guard await dropRelease.wait() else { throw ShelfFailure.cancelled } + return .text("Late drop") + }) + let fixture = Fixture( + root: root, file: file, secondFile: secondFile, folder: folder, store: store, + access: access, chooser: chooser, service: service, dropEntered: dropEntered, dropRelease: dropRelease) + service.start() + do { try await body(fixture) } catch { + chooser.release.resolve(true) + dropRelease.resolve(true) + await service.shutdown() + await chooser.finish() + throw error + } + chooser.release.resolve(true) + dropRelease.resolve(true) + await service.shutdown() + await chooser.finish() + #expect(access.balanced) + #expect(try Data(contentsOf: file) == Data("original bytes".utf8)) + #expect(try Data(contentsOf: secondFile) == Data("second bytes".utf8)) + #expect(FileManager.default.fileExists(atPath: folder.path)) + } + + private func select(_ urls: [URL]?, in f: Fixture) async throws { + f.chooser.selection = urls + try #require(f.service.chooseFiles()) + try #require(await f.chooser.entered.wait()) + f.chooser.release.resolve(true) + try #require(await ShelfSelectionCompletion(f.service).wait()) + } + + @Test("A held picker survives cancellation until its explicit release") + func heldPickerRequiresExplicitRelease() async throws { + let chooser = ShelfSelectionChooser() + let expected = [URL(fileURLWithPath: "/selection-fixture.txt")] + chooser.selection = expected + let finished = ShelfSelectionSignal() + let request = Task { + let result = await chooser.chooseFiles() + finished.resolve(true) + return result + } + let entered = await chooser.entered.wait() + request.cancel() + chooser.cancel() + let cancellationObserved = await chooser.cancelled.wait() + #expect(entered) + #expect(cancellationObserved) + #expect(!chooser.release.isResolved) + #expect(!finished.isResolved) + chooser.release.resolve(true) + #expect(await request.value == expected) + await chooser.finish() + } + + @Test("Selected files and folders remain references; optional bookmarks reload", arguments: [false, true]) + func referencesAndPersistence(persistent: Bool) async throws { + try await withFixture { f in + if persistent { + f.service.setDefaultExpiry(.oneHour) + f.service.setPersistence(true) + try #require(f.service.persistenceEnabled) + } + let urls = [f.file, f.secondFile, f.folder] + try await select(urls, in: f) + #expect(f.service.items.map { f.service.fileURL(for: $0) } == urls.map(Optional.some)) + #expect( + f.service.items.map { f.service.fileStates[$0.id] } == [ + .available(isDirectory: false), .available(isDirectory: false), .available(isDirectory: true), + ]) + #expect(f.access.begins == 3) + #expect(f.access.bookmarks == (persistent ? 3 : 0)) + #expect(f.service.importCount == 0) + #expect(!f.service.isChoosingFiles) + #expect(f.service.canChooseFiles) + #expect(f.service.message == nil) + for item in f.service.items { + guard case .file(_, let bookmark) = item.payload else { + Issue.record("Selection must retain file references.") + continue + } + #expect((bookmark != nil) == persistent) + } + if persistent { + let saved = f.service.items + await f.service.shutdown() + let restored = ShelfService(store: f.store, access: f.access, fileChooser: ShelfSelectionChooser()) + restored.start() + #expect(restored.items == saved) + #expect(f.access.resolutions == 3) + #expect(restored.items.map { restored.fileURL(for: $0) } == urls.map(Optional.some)) + await restored.shutdown() + } else { + #expect(!FileManager.default.fileExists(atPath: f.store.manifest.path)) + } + } + } + + @Test("Cancelling the picker is silent and adds no references") + func silentCancellation() async throws { + try await withFixture { f in + try await select(nil, in: f) + #expect(f.service.items.isEmpty) + #expect(f.service.message == nil) + #expect(f.service.canChooseFiles) + #expect(f.access.begins == 0) + } + } + + @Test("Capacity is checked before opening and before accepting the entire selection", arguments: [99, 100]) + func capacity(count: Int) async throws { + try await withFixture { f in + for index in 0...success(()) + case .shutdown: + await f.service.shutdown() + return Result.success(()) + } + } + let cancelled = await f.chooser.cancelled.wait() + if !cancelled { f.chooser.release.resolve(true) } + #expect(cancelled) + #expect(!finished.isResolved) + #expect(!f.service.canChooseFiles) + #expect(!f.service.chooseFiles()) + f.chooser.release.resolve(true) + try await stopping.value.get() + #expect(f.service.items.isEmpty) + #expect(f.service.importCount == 0) + #expect(!f.service.isChoosingFiles) + #expect(f.access.begins == 0) + #expect(f.service.isRunning == (action == .cancelImports || action == .clear)) + } + } + + @Test( + "Unavailable selected references use the same state and action rules as addFile", + arguments: [ + ShelfFileState.missing, .cloudOnly, .inaccessible, + ]) + func unavailableReferences(state: ShelfFileState) async throws { + try await withFixture(state: state) { f in + try await select([f.file], in: f) + let selected = try #require(f.service.items.first) + #expect(f.service.fileStates[selected.id] == state) + #expect(f.service.prepareFileAction(selected) == nil) + let selectedMessage = f.service.message + try f.service.addFile(f.file) + let direct = try #require(f.service.items.last) + #expect(selected.payload == direct.payload) + #expect(f.service.fileStates[direct.id] == state) + #expect(f.service.prepareFileAction(direct) == nil) + #expect(f.service.message == selectedMessage) + } + } + + @Test("A selected file bookmark failure is reported and releases its acquired scope") + func bookmarkFailure() async throws { + try await withFixture(failBookmarks: true) { f in + f.service.setDefaultExpiry(.oneHour) + f.service.setPersistence(true) + try #require(f.service.persistenceEnabled) + try await select([f.file], in: f) + #expect(f.service.items.isEmpty) + #expect(f.service.message == ShelfFailure.inaccessible.localizedDescription) + #expect(f.access.begins == 1) + #expect(f.access.balanced) + #expect(try f.store.load()?.items.isEmpty == true) + } + } +} diff --git a/guide/shelf-file-selection.md b/guide/shelf-file-selection.md new file mode 100644 index 0000000..710d7d4 --- /dev/null +++ b/guide/shelf-file-selection.md @@ -0,0 +1,15 @@ +# Add files to File Shelf + +Use **Choose Files…** in either the compact File Shelf card or its detail window. With that view active, **Command-O** opens the same native file picker. Start File Shelf first if it is paused. + +Select one or more files or folders, then choose **Add to Shelf**. File Shelf keeps references to those items. Originals stay in place, and selecting a folder does not copy its contents. The shelf holds up to 100 items; a selection that exceeds the remaining space is rejected before any of its items are added. + +Choose **Cancel** in the picker to leave the shelf unchanged. Once a selection starts importing, **Cancel Import** stops the remaining items; references already added stay on the shelf. Pause, Clear Shelf, and quitting also cancel pending selection and wait for it to finish. Clear Shelf removes shelf references, not the original files. + +macOS can refuse access to a selected item. File Shelf reports failures and shows the state of unavailable references. Check access in Finder, then choose the item again if needed. Cloud-only items are not downloaded by File Shelf; download them in Finder before using file actions, then refresh the shelf. + +**Keep shelf between launches** is off by default. When enabled, eligible file references use local bookmarks; the selected files are still not copied. + +## Native acceptance pending + +Automated logic tests cover selection, references, bookmarks, capacity, cancellation, and lifecycle draining. Native dialog focus, cancellation, keyboard navigation, and Command-O routing between compact and detail views still require the quality manager's desktop acceptance pass.