diff --git a/Resources/Info.plist b/Resources/Info.plist index 848e05d..ea3279d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,7 +13,7 @@ CFBundleName YanDisk CFBundleDisplayName - YanDisk + YanDisk Beta CFBundlePackageType APPL CFBundleShortVersionString diff --git a/Sources/YanDisk/Core/AppStore.swift b/Sources/YanDisk/Core/AppStore.swift index 62fab79..83ebbd1 100644 --- a/Sources/YanDisk/Core/AppStore.swift +++ b/Sources/YanDisk/Core/AppStore.swift @@ -100,6 +100,28 @@ final class AppStore { transfer.onQueueIdle = { [weak self] in Task { await self?.reloadAfterTransfer() } } + TransferBridge.shared.transfer = transfer + } + + // MARK: - Copy / Paste (⌘C — обещания файлов в Finder, ⌘V — загрузка) + + func copySelectionToPasteboard() { + let selected = visibleSelection + guard !selected.isEmpty else { return } + CloudPasteboard.copy(items: selected) + statusMessage = "Скопировано в буфер: \(selected.count). Вставь ⌘V в Finder — файл скачается." + } + + func pasteFromPasteboard() { + let urls = CloudPasteboard.pasteboardFileURLs() + guard !urls.isEmpty else { return } + Task { await uploadFiles(from: urls) } + } + + /// Выбранные ресурсы (по path) среди видимых элементов. + var visibleSelection: [DiskResource] { + let all = items + return all.filter { selection.contains($0.path) } } private var didBootstrap = false diff --git a/Sources/YanDisk/Transfer/CloudFileDrag.swift b/Sources/YanDisk/Transfer/CloudFileDrag.swift index 95f79c6..f3d9f2d 100644 --- a/Sources/YanDisk/Transfer/CloudFileDrag.swift +++ b/Sources/YanDisk/Transfer/CloudFileDrag.swift @@ -1,16 +1,18 @@ -import CoreTransferable +import AppKit import Foundation import UniformTypeIdentifiers -/// Мост для экспорта облачных файлов при drag & drop: драг даёт Finder -/// «обещание» файла, а реальное скачивание начинается в момент дропа. +/// Мост для экспорта облачных файлов при drag & drop и копировании (⌘C): +/// драг/пастборд даёт Finder «обещание» файла (NSFilePromiseProvider), +/// реальное скачивание начинается в момент дропа/вставки — без блокировок. @MainActor final class TransferBridge { static let shared = TransferBridge() var client: DiskAPIClient? - var onActivity: ((String) -> Void)? + weak var transfer: TransferManager? - /// Скачать ресурс во временную папку и вернуть готовый файл/архив. + /// Скачать ресурс во временную папку и вернуть готовый файл/архив + /// (для драгов через NSItemProvider). func materialize(path: String, name: String, isDirectory: Bool) async throws -> URL { guard let client else { throw DiskAPIError.unauthorized } let fm = FileManager.default @@ -54,42 +56,128 @@ final class TransferBridge { throw DiskAPIError.transport("Не удалось упаковать папку в архив") } } +} - /// Синхронный экспорт (требование API Transferable): ждём скачивание - /// через семафор — загрузка идёт в фоне, дроп просто дождётся файла. - nonisolated static func exportBlocking(path: String, name: String, isDirectory: Bool) throws -> URL { - let semaphore = DispatchSemaphore(value: 0) - nonisolated(unsafe) var outcome: Result? - Task.detached(priority: .userInitiated) { - let result: Result - do { - let url = try await TransferBridge.shared.materialize(path: path, name: name, isDirectory: isDirectory) - result = .success(url) - } catch { - result = .failure(error) +// MARK: - Drag source (NSItemProvider с ленивой file representation) + +/// NSItemProvider для драга облачного ресурса: файл скачивается только +/// тогда, когда получатель (Finder) запросит его — на drop. +@MainActor +enum CloudDrag { + static func provider(for item: DiskResource) -> NSItemProvider { + let provider = NSItemProvider() + let name = item.isDirectory ? "\(item.displayName).zip" : item.displayName + provider.suggestedName = name + let ext = (item.displayName as NSString).pathExtension.lowercased() + let type = item.isDirectory + ? UTType.zip + : (UTType(filenameExtension: ext) ?? .data) + provider.registerFileRepresentation( + forTypeIdentifier: type.identifier, + fileOptions: [], + visibility: .all + ) { completion in + Task.detached(priority: .userInitiated) { + do { + let url = try await TransferBridge.shared.materialize( + path: item.path, + name: name, + isDirectory: item.isDirectory + ) + completion(url, false, nil) + } catch { + completion(nil, false, error) + } } - outcome = result - semaphore.signal() + return nil } - semaphore.wait() - return try outcome.unsafelyUnwrapped.get() + return provider } } -/// Элемент из облака: перетаскивается из приложения, материализуется при дропе. -/// Папка при дропе скачивается рекурсивно и отдаётся zip-архивом. -struct CloudItem: Transferable, Hashable, Sendable { - let path: String - let name: String +// MARK: - Pasteboard promises (⌘C → ⌘V в Finder) + +/// Делегат NSFilePromiseProvider: по запросу получателя скачивает ресурс +/// точно в указанное место (через очередь передач — с докачкой и прогрессом). +final class CloudPromiseDelegate: NSObject, NSFilePromiseProviderDelegate { + let remotePath: String + let displayName: String let isDirectory: Bool - static var transferRepresentation: some TransferRepresentation { - FileRepresentation(exportedContentType: .data) { item in - SentTransferredFile(try TransferBridge.exportBlocking( - path: item.path, - name: item.isDirectory ? "\(item.name).zip" : item.name, - isDirectory: item.isDirectory - )) + init(remotePath: String, displayName: String, isDirectory: Bool) { + self.remotePath = remotePath + self.displayName = displayName + self.isDirectory = isDirectory + super.init() + } + + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String { + isDirectory ? "\(displayName).zip" : displayName + } + + func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue { + .main + } + + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping (Error?) -> Void) { + Task { @MainActor in + let fileName = isDirectory ? "\(displayName).zip" : displayName + if isDirectory { + // Папка: скачиваем во временную папку, zip'уем, кладём в url. + Task.detached(priority: .userInitiated) { [remotePath, displayName] in + do { + let zip = try await TransferBridge.shared.materialize(path: remotePath, name: displayName, isDirectory: true) + let fm = FileManager.default + if fm.fileExists(atPath: url.path) { try fm.removeItem(at: url) } + try fm.moveItem(at: zip, to: url) + await MainActor.run { completionHandler(nil) } + } catch { + await MainActor.run { completionHandler(error) } + } + } + } else { + guard let transfer = TransferBridge.shared.transfer else { + completionHandler(DiskAPIError.unauthorized) + return + } + transfer.enqueuePromisedDownload(remotePath: remotePath, destFile: url.appendingPathComponent(fileName)) { error in + completionHandler(error) + } + } } } + + func promiseProvider() -> NSFilePromiseProvider { + let ext = (displayName as NSString).pathExtension.lowercased() + let type = isDirectory + ? UTType.zip.identifier + : (UTType(filenameExtension: ext) ?? UTType.data).identifier + return NSFilePromiseProvider(fileType: type, delegate: self) + } +} + +@MainActor +enum CloudPasteboard { + /// ⌘C: положить в пастборд обещания файлов для выбранных ресурсов. + static func copy(items: [DiskResource]) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let providers: [NSFilePromiseProvider] = items.map { item in + CloudPromiseDelegate( + remotePath: item.path, + displayName: item.displayName, + isDirectory: item.isDirectory + ).promiseProvider() + } + pasteboard.writeObjects(providers) + } + + /// ⌘V внутри приложения: файлы из пастборда — в очередь загрузки. + static func pasteboardFileURLs() -> [URL] { + let pasteboard = NSPasteboard.general + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] else { + return [] + } + return urls.filter { $0.isFileURL } + } } diff --git a/Sources/YanDisk/Transfer/TransferManager.swift b/Sources/YanDisk/Transfer/TransferManager.swift index 7009d0e..53d0a72 100644 --- a/Sources/YanDisk/Transfer/TransferManager.swift +++ b/Sources/YanDisk/Transfer/TransferManager.swift @@ -45,6 +45,36 @@ final class TransferManager { /// Вызывается, когда очередь опустела (для обновления списков в UI). var onQueueIdle: (() -> Void)? + /// Колбэки завершения для драг/пастборд-промисов. + private var completions: [UUID: (Error?) -> Void] = [:] + + // MARK: - Индикаторы активности (⬇ зелёный / ⬆ оранжевый) + + struct ActivityIndicator: Equatable { + var isActive = false + var progress: Double = 0 + var isIndeterminate = true + } + + var downloadActivity: ActivityIndicator { + activity(for: .download) + } + + var uploadActivity: ActivityIndicator { + activity(for: .upload) + } + + private func activity(for kind: TransferItem.Kind) -> ActivityIndicator { + let active = items.filter { $0.kind == kind && ($0.state == .queued || $0.state == .running) } + guard !active.isEmpty else { return ActivityIndicator() } + let done = active.reduce(Int64(0)) { $0 + $1.bytesDone } + let total = active.reduce(Int64(0)) { $0 + $1.bytesTotal } + if total > 0, done >= 0 { + return ActivityIndicator(isActive: true, progress: min(1, Double(done) / Double(total)), isIndeterminate: false) + } + return ActivityIndicator(isActive: true, progress: 0, isIndeterminate: true) + } + private static var manifestURL: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] return base.appendingPathComponent("YanDisk/transfers.json") @@ -96,6 +126,22 @@ final class TransferManager { return item } + /// Скачивание по промису (⌘C → ⌘V в Finder): файл кладётся ровно в destFile, + /// completion вызывается по завершении (Finder ждёт его). + @discardableResult + func enqueuePromisedDownload(remotePath: String, destFile: URL, completion: @escaping (Error?) -> Void) -> TransferItem { + let item = TransferItem( + id: UUID(), kind: .download, remotePath: remotePath, + destPath: destFile.path + ) + completions[item.id] = completion + items.insert(item, at: 0) + if items.count > 200 { items.removeLast(items.count - 200) } + persist() + kick() + return item + } + func retryAll() { for i in items.indices where items[i].state == .failed { items[i].state = .queued @@ -146,6 +192,10 @@ final class TransferManager { items[index].state = .failed items[index].error = error.localizedDescription } + let finishedItem = items[index] + if let completion = completions.removeValue(forKey: finishedItem.id) { + completion(finishedItem.state == .completed ? nil : DiskAPIError.transport(finishedItem.error ?? "Передача не выполнена")) + } persist() } if !items.contains(where: { $0.state == .queued || $0.state == .running }) { diff --git a/Sources/YanDisk/UI/MainSplitView.swift b/Sources/YanDisk/UI/MainSplitView.swift index 12fcaa1..60bcf59 100644 --- a/Sources/YanDisk/UI/MainSplitView.swift +++ b/Sources/YanDisk/UI/MainSplitView.swift @@ -44,6 +44,7 @@ struct MainSplitView: View { if let status = store.statusMessage { Divider() HStack { + TransferActivityIndicator() Image(systemName: "checkmark.circle") Text(status) Spacer() @@ -52,6 +53,15 @@ struct MainSplitView: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(.thinMaterial) + } else { + Divider() + HStack { + TransferActivityIndicator() + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.thinMaterial) } } } @@ -132,6 +142,16 @@ struct MainSplitView: View { Button("Отмена", role: .cancel) { renameItem = nil } } .onAppear { store.bootstrap() } + .background { + // Горячие клавиши: ⌘C — обещания файлов в буфер (⌘V в Finder скачает), + // ⌘V — файлы из буфера в очередь загрузки. + Button("Копировать") { store.copySelectionToPasteboard() } + .keyboardShortcut("c", modifiers: .command) + .opacity(0) + Button("Вставить") { store.pasteFromPasteboard() } + .keyboardShortcut("v", modifiers: .command) + .opacity(0) + } .onChange(of: store.sidebarSelection) { _, newValue in store.selectSidebar(newValue) } @@ -223,58 +243,16 @@ struct MainSplitView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - Table(items, selection: Binding( + List(selection: Binding( get: { store.selection }, set: { store.selection = $0 } )) { - TableColumn("Имя") { item in - HStack(spacing: 8) { - Image(systemName: icon(for: item)) - .foregroundStyle(item.isDirectory ? Color.accentColor : .secondary) - .frame(width: 18) - Text(item.displayName) - .lineLimit(1) - if store.sidebarSelection != .files && store.isFavorite(item) { - Image(systemName: "star.fill") - .font(.caption2) - .foregroundStyle(.yellow) - } - if item.publicUrl != nil { - Image(systemName: "link") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .draggable(dragItem(for: item)) { - dragPreview(for: item) - } - } - TableColumn("Размер") { item in - Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize)) - .foregroundStyle(.secondary) - } - .width(100) - TableColumn("Изменён") { item in - Text(formattedDate(item.modified)) - .foregroundStyle(.secondary) - } - .width(140) - TableColumn("Синхр.") { item in - Text(store.syncEngine.policy.mode(for: item.path).shortTitle) - .foregroundStyle(.secondary) - .font(.caption) - } - .width(100) - } - .contextMenu(forSelectionType: String.self) { ids in - if let path = ids.first, let item = items.first(where: { $0.path == path }) { - contextMenu(store: store, item: item) - } - } primaryAction: { ids in - if let path = ids.first, let item = items.first(where: { $0.path == path }) { - store.open(item) + ForEach(items) { item in + row(store: store, item: item) + .tag(item.path) } } + .listStyle(.inset(alternatesRowBackgrounds: true)) .dropDestination(for: URL.self) { urls, _ in let files = urls.filter { $0.isFileURL } guard !files.isEmpty else { return false } @@ -302,6 +280,43 @@ struct MainSplitView: View { } } + private func row(store: AppStore, item: DiskResource) -> some View { + HStack(spacing: 8) { + Image(systemName: icon(for: item)) + .foregroundStyle(item.isDirectory ? Color.accentColor : .secondary) + .frame(width: 18) + HStack(spacing: 4) { + Text(item.displayName) + .lineLimit(1) + if store.sidebarSelection != .files && store.isFavorite(item) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + if item.publicUrl != nil { + Image(systemName: "link") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize)) + .foregroundStyle(.secondary) + .frame(width: 100, alignment: .trailing) + Text(formattedDate(item.modified)) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Text(store.syncEngine.policy.mode(for: item.path).shortTitle) + .foregroundStyle(.secondary) + .font(.caption) + .frame(width: 100, alignment: .leading) + } + .contentShape(Rectangle()) + .onDrag { CloudDrag.provider(for: item) } + .contextMenu { contextMenu(store: store, item: item) } + .onTapGesture(count: 2) { store.open(item) } + } + @ViewBuilder private func contextMenu(store: AppStore, item: DiskResource) -> some View { if store.sidebarSelection == .trash { @@ -360,18 +375,6 @@ struct MainSplitView: View { } } - private func dragItem(for item: DiskResource) -> CloudItem { - CloudItem(path: item.path, name: item.displayName, isDirectory: item.isDirectory) - } - - private func dragPreview(for item: DiskResource) -> some View { - HStack(spacing: 6) { - Image(systemName: icon(for: item)) - Text(item.displayName) - } - .padding(6) - } - private func icon(for item: DiskResource) -> String { if item.isDirectory { return "folder.fill" } let ext = (item.displayName as NSString).pathExtension.lowercased() @@ -402,6 +405,35 @@ struct MainSplitView: View { } } +struct TransferActivityIndicator: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 12) { + gauge(color: .green, icon: "arrow.down", activity: store.transfer.downloadActivity, label: "Download") + gauge(color: .orange, icon: "arrow.up", activity: store.transfer.uploadActivity, label: "Upload") + } + } + + private func gauge(color: Color, icon: String, activity: TransferManager.ActivityIndicator, label: String) -> some View { + HStack(spacing: 5) { + Image(systemName: icon) + .foregroundStyle(activity.isActive ? color : color.opacity(0.35)) + Group { + if activity.isIndeterminate || !activity.isActive { + ProgressView(value: activity.isActive ? 0.05 : 0) + .tint(color.opacity(activity.isActive ? 1 : 0.25)) + } else { + ProgressView(value: activity.progress) + .tint(color) + } + } + .frame(width: 90) + } + .help("\(label): \(activity.isActive ? "идёт передача" : "нет активности")") + } +} + struct TasksView: View { @Environment(AppStore.self) private var store