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