Fix: драг через NSItemProvider file-promise (без блокировок, скачивание на дропе); ⌘C — file promises в пастборд (⌘V в Finder = скачивание), ⌘V — загрузка; индикатор ⬇(зел)/⬆(оранж) в статус-баре; браузер переведён с Table на List
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>YanDisk</string>
|
<string>YanDisk</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>YanDisk</string>
|
<string>YanDisk Beta</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -100,6 +100,28 @@ final class AppStore {
|
|||||||
transfer.onQueueIdle = { [weak self] in
|
transfer.onQueueIdle = { [weak self] in
|
||||||
Task { await self?.reloadAfterTransfer() }
|
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
|
private var didBootstrap = false
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import CoreTransferable
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// Мост для экспорта облачных файлов при drag & drop: драг даёт Finder
|
/// Мост для экспорта облачных файлов при drag & drop и копировании (⌘C):
|
||||||
/// «обещание» файла, а реальное скачивание начинается в момент дропа.
|
/// драг/пастборд даёт Finder «обещание» файла (NSFilePromiseProvider),
|
||||||
|
/// реальное скачивание начинается в момент дропа/вставки — без блокировок.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TransferBridge {
|
final class TransferBridge {
|
||||||
static let shared = TransferBridge()
|
static let shared = TransferBridge()
|
||||||
var client: DiskAPIClient?
|
var client: DiskAPIClient?
|
||||||
var onActivity: ((String) -> Void)?
|
weak var transfer: TransferManager?
|
||||||
|
|
||||||
/// Скачать ресурс во временную папку и вернуть готовый файл/архив.
|
/// Скачать ресурс во временную папку и вернуть готовый файл/архив
|
||||||
|
/// (для драгов через NSItemProvider).
|
||||||
func materialize(path: String, name: String, isDirectory: Bool) async throws -> URL {
|
func materialize(path: String, name: String, isDirectory: Bool) async throws -> URL {
|
||||||
guard let client else { throw DiskAPIError.unauthorized }
|
guard let client else { throw DiskAPIError.unauthorized }
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
@@ -54,42 +56,128 @@ final class TransferBridge {
|
|||||||
throw DiskAPIError.transport("Не удалось упаковать папку в архив")
|
throw DiskAPIError.transport("Не удалось упаковать папку в архив")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Синхронный экспорт (требование API Transferable): ждём скачивание
|
// MARK: - Drag source (NSItemProvider с ленивой file representation)
|
||||||
/// через семафор — загрузка идёт в фоне, дроп просто дождётся файла.
|
|
||||||
nonisolated static func exportBlocking(path: String, name: String, isDirectory: Bool) throws -> URL {
|
/// NSItemProvider для драга облачного ресурса: файл скачивается только
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
/// тогда, когда получатель (Finder) запросит его — на drop.
|
||||||
nonisolated(unsafe) var outcome: Result<URL, Error>?
|
@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) {
|
Task.detached(priority: .userInitiated) {
|
||||||
let result: Result<URL, Error>
|
|
||||||
do {
|
do {
|
||||||
let url = try await TransferBridge.shared.materialize(path: path, name: name, isDirectory: isDirectory)
|
let url = try await TransferBridge.shared.materialize(
|
||||||
result = .success(url)
|
path: item.path,
|
||||||
|
name: name,
|
||||||
|
isDirectory: item.isDirectory
|
||||||
|
)
|
||||||
|
completion(url, false, nil)
|
||||||
} catch {
|
} catch {
|
||||||
result = .failure(error)
|
completion(nil, false, error)
|
||||||
}
|
}
|
||||||
outcome = result
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
}
|
||||||
semaphore.wait()
|
return nil
|
||||||
return try outcome.unsafelyUnwrapped.get()
|
}
|
||||||
|
return provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Элемент из облака: перетаскивается из приложения, материализуется при дропе.
|
// MARK: - Pasteboard promises (⌘C → ⌘V в Finder)
|
||||||
/// Папка при дропе скачивается рекурсивно и отдаётся zip-архивом.
|
|
||||||
struct CloudItem: Transferable, Hashable, Sendable {
|
/// Делегат NSFilePromiseProvider: по запросу получателя скачивает ресурс
|
||||||
let path: String
|
/// точно в указанное место (через очередь передач — с докачкой и прогрессом).
|
||||||
let name: String
|
final class CloudPromiseDelegate: NSObject, NSFilePromiseProviderDelegate {
|
||||||
|
let remotePath: String
|
||||||
|
let displayName: String
|
||||||
let isDirectory: Bool
|
let isDirectory: Bool
|
||||||
|
|
||||||
static var transferRepresentation: some TransferRepresentation {
|
init(remotePath: String, displayName: String, isDirectory: Bool) {
|
||||||
FileRepresentation(exportedContentType: .data) { item in
|
self.remotePath = remotePath
|
||||||
SentTransferredFile(try TransferBridge.exportBlocking(
|
self.displayName = displayName
|
||||||
path: item.path,
|
self.isDirectory = isDirectory
|
||||||
name: item.isDirectory ? "\(item.name).zip" : item.name,
|
super.init()
|
||||||
isDirectory: item.isDirectory
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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).
|
/// Вызывается, когда очередь опустела (для обновления списков в UI).
|
||||||
var onQueueIdle: (() -> Void)?
|
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 {
|
private static var manifestURL: URL {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return base.appendingPathComponent("YanDisk/transfers.json")
|
return base.appendingPathComponent("YanDisk/transfers.json")
|
||||||
@@ -96,6 +126,22 @@ final class TransferManager {
|
|||||||
return item
|
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() {
|
func retryAll() {
|
||||||
for i in items.indices where items[i].state == .failed {
|
for i in items.indices where items[i].state == .failed {
|
||||||
items[i].state = .queued
|
items[i].state = .queued
|
||||||
@@ -146,6 +192,10 @@ final class TransferManager {
|
|||||||
items[index].state = .failed
|
items[index].state = .failed
|
||||||
items[index].error = error.localizedDescription
|
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()
|
persist()
|
||||||
}
|
}
|
||||||
if !items.contains(where: { $0.state == .queued || $0.state == .running }) {
|
if !items.contains(where: { $0.state == .queued || $0.state == .running }) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct MainSplitView: View {
|
|||||||
if let status = store.statusMessage {
|
if let status = store.statusMessage {
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
|
TransferActivityIndicator()
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
Text(status)
|
Text(status)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -52,6 +53,15 @@ struct MainSplitView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(.thinMaterial)
|
.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 }
|
Button("Отмена", role: .cancel) { renameItem = nil }
|
||||||
}
|
}
|
||||||
.onAppear { store.bootstrap() }
|
.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
|
.onChange(of: store.sidebarSelection) { _, newValue in
|
||||||
store.selectSidebar(newValue)
|
store.selectSidebar(newValue)
|
||||||
}
|
}
|
||||||
@@ -223,58 +243,16 @@ struct MainSplitView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
Table(items, selection: Binding(
|
List(selection: Binding(
|
||||||
get: { store.selection },
|
get: { store.selection },
|
||||||
set: { store.selection = $0 }
|
set: { store.selection = $0 }
|
||||||
)) {
|
)) {
|
||||||
TableColumn("Имя") { item in
|
ForEach(items) { item in
|
||||||
HStack(spacing: 8) {
|
row(store: store, item: item)
|
||||||
Image(systemName: icon(for: item))
|
.tag(item.path)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||||
.dropDestination(for: URL.self) { urls, _ in
|
.dropDestination(for: URL.self) { urls, _ in
|
||||||
let files = urls.filter { $0.isFileURL }
|
let files = urls.filter { $0.isFileURL }
|
||||||
guard !files.isEmpty else { return false }
|
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
|
@ViewBuilder
|
||||||
private func contextMenu(store: AppStore, item: DiskResource) -> some View {
|
private func contextMenu(store: AppStore, item: DiskResource) -> some View {
|
||||||
if store.sidebarSelection == .trash {
|
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 {
|
private func icon(for item: DiskResource) -> String {
|
||||||
if item.isDirectory { return "folder.fill" }
|
if item.isDirectory { return "folder.fill" }
|
||||||
let ext = (item.displayName as NSString).pathExtension.lowercased()
|
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 {
|
struct TasksView: View {
|
||||||
@Environment(AppStore.self) private var store
|
@Environment(AppStore.self) private var store
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user