import SwiftUI import AppKit import UniformTypeIdentifiers // MARK: - File shelf // // Drop files on the notch → "На полку": they wait in the top-left slot of the home // view. Drag an icon out to put the file anywhere, click to open it, right-click to // reveal or remove. The shelf keeps references (paths), not copies, and survives restarts; // files that no longer exist are dropped silently. struct ShelfItem: Identifiable, Codable, Equatable { var id = UUID() var path: String var addedAt = Date() var url: URL { URL(fileURLWithPath: path) } var name: String { url.lastPathComponent } } @MainActor final class FileShelf: ObservableObject { static let shared = FileShelf() static let maxItems = 12 private let key = "fileShelf" @Published private(set) var items: [ShelfItem] = [] private init() { if let data = UserDefaults.standard.data(forKey: key), let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) { items = saved.filter { FileManager.default.fileExists(atPath: $0.path) } } } func add(_ urls: [URL]) { for url in urls where !items.contains(where: { $0.path == url.path }) { items.insert(ShelfItem(path: url.path), at: 0) } if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) } save() } func remove(_ item: ShelfItem) { items.removeAll { $0.id == item.id } save() } func clear() { items = [] save() } /// Drops entries whose file was moved or deleted. func prune() { let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) } if alive != items { items = alive; save() } } private func save() { if let data = try? JSONEncoder().encode(items) { UserDefaults.standard.set(data, forKey: key) } } } // MARK: - Shelf pill (home view, top-left slot) struct ShelfPill: View { @ObservedObject private var shelf = FileShelf.shared var body: some View { ZStack { if shelf.items.isEmpty { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4])) Text("полка · брось файл на чёлку") .font(.system(size: 10.5)) .foregroundColor(Color(hex: "#4A4E55")) } else { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(hex: "#0E0F11")) RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(Color.white.opacity(0.10), lineWidth: 1) HStack(spacing: 6) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { ForEach(shelf.items) { item in ShelfIcon(item: item) } } .padding(.horizontal, 2) } Button { shelf.clear() SoundEngine.shared.play("close") } label: { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) .foregroundColor(Color(hex: "#5F646D")) .frame(width: 16, height: 16) .background(Circle().fill(Color.white.opacity(0.06))) } .buttonStyle(.plain) .help("Очистить полку") } .padding(.horizontal, 8) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { shelf.prune() } } } struct ShelfIcon: View { let item: ShelfItem @State private var isHovered = false var body: some View { Image(nsImage: NSWorkspace.shared.icon(forFile: item.path)) .resizable() .interpolation(.high) .frame(width: 28, height: 28) .padding(2) .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0))) .scaleEffect(isHovered ? 1.08 : 1) .onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } } .help(item.name) .onTapGesture { NSWorkspace.shared.open(item.url) } .onDrag { SoundEngine.shared.play("pop") return NSItemProvider(contentsOf: item.url) ?? NSItemProvider() } .contextMenu { Button("Открыть") { NSWorkspace.shared.open(item.url) } Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } Divider() Button("Убрать с полки") { FileShelf.shared.remove(item) } } } }