37c01f991a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.0 KiB
Swift
142 lines
5.0 KiB
Swift
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) }
|
||
}
|
||
}
|
||
}
|