Fix: драг через NSItemProvider file-promise (без блокировок, скачивание на дропе); ⌘C — file promises в пастборд (⌘V в Finder = скачивание), ⌘V — загрузка; индикатор ⬇(зел)/⬆(оранж) в статус-баре; браузер переведён с Table на List

This commit is contained in:
statix05
2026-09-16 14:51:38 +03:00
parent 7eab140e92
commit 632cd992bc
5 changed files with 285 additions and 93 deletions
+1 -1
View File
@@ -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>
+22
View File
@@ -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
+118 -30
View File
@@ -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 }) {
+91 -59
View File
@@ -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