Fix: драг через NSItemProvider file-promise (без блокировок, скачивание на дропе); ⌘C — file promises в пастборд (⌘V в Finder = скачивание), ⌘V — загрузка; индикатор ⬇(зел)/⬆(оранж) в статус-баре; браузер переведён с Table на List
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>YanDisk</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>YanDisk</string>
|
||||
<string>YanDisk Beta</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<URL, 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) {
|
||||
let result: Result<URL, Error>
|
||||
do {
|
||||
let url = try await TransferBridge.shared.materialize(path: path, name: name, isDirectory: isDirectory)
|
||||
result = .success(url)
|
||||
let url = try await TransferBridge.shared.materialize(
|
||||
path: item.path,
|
||||
name: name,
|
||||
isDirectory: item.isDirectory
|
||||
)
|
||||
completion(url, false, nil)
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
completion(nil, false, error)
|
||||
}
|
||||
outcome = result
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return try outcome.unsafelyUnwrapped.get()
|
||||
return nil
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user