Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Semper/Shelf/ShelfFileSelection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions Semper/Shelf/ShelfModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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."
Expand Down
68 changes: 62 additions & 6 deletions Semper/Shelf/ShelfService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ 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<String> = []
private var importCleanupNeedsRetry = false
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
Expand All @@ -39,7 +45,8 @@ final class ShelfService {
@ObservationIgnored private var generation = 0
@ObservationIgnored private var clearGeneration = 0
@ObservationIgnored private var clearTask: Task<Result<Void, ShelfFailure>, Never>?
@ObservationIgnored private var removingIDs: Set<UUID> = []
private var removingIDs: Set<UUID> = []
@ObservationIgnored private let fileChooser: any ShelfFileChoosing
@ObservationIgnored private let importer:
@MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload
@ObservationIgnored private var stopTask: Task<Void, Never>?
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions Semper/Shelf/ShelfViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading