|
| 1 | +// FinderSidebarView.swift |
| 2 | +// MiMiNavigator |
| 3 | +// |
| 4 | +// Created by Iakov Senatov on 27.04.2026. |
| 5 | +// Copyright © 2026 Senatov. All rights reserved. |
| 6 | +// Description: Finder-style source list embedded in the main dual-panel window. |
| 7 | + |
| 8 | +import AppKit |
| 9 | +import FileModelKit |
| 10 | +import SwiftUI |
| 11 | + |
| 12 | +// MARK: - Finder Sidebar View |
| 13 | +struct FinderSidebarView: View { |
| 14 | + let appState: AppState |
| 15 | + @State private var volumes: [FinderSidebarItem] = [] |
| 16 | + @State private var selectedID: String? |
| 17 | + |
| 18 | + private enum Layout { |
| 19 | + static let rowHeight: CGFloat = 26 |
| 20 | + static let iconWidth: CGFloat = 18 |
| 21 | + static let horizontalPadding: CGFloat = 10 |
| 22 | + static let sectionSpacing: CGFloat = 10 |
| 23 | + } |
| 24 | + |
| 25 | + // MARK: - Body |
| 26 | + var body: some View { |
| 27 | + ScrollView { |
| 28 | + VStack(alignment: .leading, spacing: Layout.sectionSpacing) { |
| 29 | + section(title: "Favorites", items: favoriteItems) |
| 30 | + section(title: "iCloud", items: iCloudItems) |
| 31 | + section(title: "Locations", items: locationItems) |
| 32 | + tagsSection |
| 33 | + Spacer(minLength: 0) |
| 34 | + } |
| 35 | + .padding(.horizontal, 10) |
| 36 | + .padding(.vertical, 12) |
| 37 | + } |
| 38 | + .background(.regularMaterial) |
| 39 | + .overlay(alignment: .trailing) { |
| 40 | + Divider() |
| 41 | + } |
| 42 | + .onAppear(perform: refreshVolumes) |
| 43 | + } |
| 44 | + |
| 45 | + // MARK: - Favorites |
| 46 | + private var favoriteItems: [FinderSidebarItem] { |
| 47 | + let home = FileManager.default.homeDirectoryForCurrentUser |
| 48 | + return [ |
| 49 | + FinderSidebarItem(title: "AirDrop", systemImage: "antenna.radiowaves.left.and.right", action: .airDrop), |
| 50 | + FinderSidebarItem(title: "Photos Library", systemImage: "photo.on.rectangle", action: .openIfExists(home.appendingPathComponent("Pictures/Photos Library.photoslibrary"))), |
| 51 | + FinderSidebarItem(title: "Documents", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Documents", isDirectory: true))), |
| 52 | + FinderSidebarItem(title: "Applications", systemImage: "folder.fill", action: .navigate(URL(fileURLWithPath: "/Applications", isDirectory: true))), |
| 53 | + FinderSidebarItem(title: "Desktop", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Desktop", isDirectory: true))), |
| 54 | + FinderSidebarItem(title: "Downloads", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Downloads", isDirectory: true))), |
| 55 | + FinderSidebarItem(title: "Movies", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Movies", isDirectory: true))), |
| 56 | + FinderSidebarItem(title: "Music", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Music", isDirectory: true))), |
| 57 | + FinderSidebarItem(title: "Pictures", systemImage: "folder.fill", action: .navigate(home.appendingPathComponent("Pictures", isDirectory: true))) |
| 58 | + ].filter(\.shouldShow) |
| 59 | + } |
| 60 | + |
| 61 | + // MARK: - iCloud |
| 62 | + private var iCloudItems: [FinderSidebarItem] { |
| 63 | + let cloudDocs = FileManager.default.homeDirectoryForCurrentUser |
| 64 | + .appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs", isDirectory: true) |
| 65 | + return [ |
| 66 | + FinderSidebarItem(title: "iCloud Drive", systemImage: "icloud", action: .navigate(cloudDocs)) |
| 67 | + ].filter(\.shouldShow) |
| 68 | + } |
| 69 | + |
| 70 | + // MARK: - Locations |
| 71 | + private var locationItems: [FinderSidebarItem] { |
| 72 | + volumes + [ |
| 73 | + FinderSidebarItem(title: "Network", systemImage: "globe", action: .network) |
| 74 | + ] |
| 75 | + } |
| 76 | + |
| 77 | + // MARK: - Tags |
| 78 | + private var tagsSection: some View { |
| 79 | + VStack(alignment: .leading, spacing: 2) { |
| 80 | + sectionHeader("Tags") |
| 81 | + ForEach(FinderSidebarTag.standard) { tag in |
| 82 | + HStack(spacing: 8) { |
| 83 | + Circle() |
| 84 | + .fill(tag.color) |
| 85 | + .frame(width: 12, height: 12) |
| 86 | + Text(tag.title) |
| 87 | + .font(.system(size: 13)) |
| 88 | + .lineLimit(1) |
| 89 | + Spacer(minLength: 0) |
| 90 | + } |
| 91 | + .frame(height: Layout.rowHeight) |
| 92 | + .padding(.horizontal, Layout.horizontalPadding) |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + // MARK: - Section |
| 98 | + private func section(title: String, items: [FinderSidebarItem]) -> some View { |
| 99 | + VStack(alignment: .leading, spacing: 2) { |
| 100 | + sectionHeader(title) |
| 101 | + ForEach(items) { item in |
| 102 | + Button { |
| 103 | + handle(item) |
| 104 | + } label: { |
| 105 | + row(item) |
| 106 | + } |
| 107 | + .buttonStyle(.plain) |
| 108 | + .help(item.helpText) |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + // MARK: - Section Header |
| 114 | + private func sectionHeader(_ title: String) -> some View { |
| 115 | + Text(title) |
| 116 | + .font(.system(size: 11, weight: .semibold)) |
| 117 | + .foregroundStyle(.secondary) |
| 118 | + .padding(.horizontal, Layout.horizontalPadding) |
| 119 | + .padding(.top, 2) |
| 120 | + } |
| 121 | + |
| 122 | + // MARK: - Row |
| 123 | + private func row(_ item: FinderSidebarItem) -> some View { |
| 124 | + HStack(spacing: 8) { |
| 125 | + Image(systemName: item.systemImage) |
| 126 | + .font(.system(size: 14)) |
| 127 | + .symbolRenderingMode(.hierarchical) |
| 128 | + .foregroundStyle(item.tint) |
| 129 | + .frame(width: Layout.iconWidth, height: Layout.iconWidth) |
| 130 | + Text(item.title) |
| 131 | + .font(.system(size: 13)) |
| 132 | + .foregroundStyle(.primary) |
| 133 | + .lineLimit(1) |
| 134 | + .truncationMode(.middle) |
| 135 | + Spacer(minLength: 0) |
| 136 | + } |
| 137 | + .frame(height: Layout.rowHeight) |
| 138 | + .padding(.horizontal, Layout.horizontalPadding) |
| 139 | + .background(selectionBackground(for: item)) |
| 140 | + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) |
| 141 | + } |
| 142 | + |
| 143 | + // MARK: - Selection Background |
| 144 | + private func selectionBackground(for item: FinderSidebarItem) -> some View { |
| 145 | + RoundedRectangle(cornerRadius: 6, style: .continuous) |
| 146 | + .fill(isSelected(item) ? Color.accentColor.opacity(0.18) : Color.clear) |
| 147 | + } |
| 148 | + |
| 149 | + // MARK: - Selection |
| 150 | + private func isSelected(_ item: FinderSidebarItem) -> Bool { |
| 151 | + guard selectedID == item.id else { return false } |
| 152 | + return true |
| 153 | + } |
| 154 | + |
| 155 | + // MARK: - Handle Item |
| 156 | + private func handle(_ item: FinderSidebarItem) { |
| 157 | + selectedID = item.id |
| 158 | + switch item.action { |
| 159 | + case .airDrop: |
| 160 | + openAirDrop() |
| 161 | + case .network: |
| 162 | + NetworkNeighborhoodCoordinator.shared.toggle() |
| 163 | + case .navigate(let url): |
| 164 | + navigate(to: url) |
| 165 | + case .openIfExists(let url): |
| 166 | + openIfExists(url) |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + // MARK: - Navigate |
| 171 | + private func navigate(to url: URL) { |
| 172 | + Task { @MainActor in |
| 173 | + let panel = appState.focusedPanel |
| 174 | + appState.updatePath(url, for: panel) |
| 175 | + await appState.scanner.clearCooldown(for: panel) |
| 176 | + await appState.scanner.refreshFiles(currSide: panel, force: true) |
| 177 | + log.info("[FinderSidebar] navigate panel=\(panel) path='\(url.path)'") |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + // MARK: - Open If Exists |
| 182 | + private func openIfExists(_ url: URL) { |
| 183 | + var isDirectory: ObjCBool = false |
| 184 | + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return } |
| 185 | + if isDirectory.boolValue { |
| 186 | + navigate(to: url) |
| 187 | + } else { |
| 188 | + NSWorkspace.shared.open(url) |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + // MARK: - Open AirDrop |
| 193 | + private func openAirDrop() { |
| 194 | + guard let url = URL(string: "airdrop://") else { return } |
| 195 | + NSWorkspace.shared.open(url) |
| 196 | + } |
| 197 | + |
| 198 | + // MARK: - Refresh Volumes |
| 199 | + private func refreshVolumes() { |
| 200 | + let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: [.volumeNameKey], options: [.skipHiddenVolumes]) ?? [] |
| 201 | + volumes = urls.compactMap(volumeItem) |
| 202 | + } |
| 203 | + |
| 204 | + // MARK: - Volume Item |
| 205 | + private func volumeItem(for url: URL) -> FinderSidebarItem? { |
| 206 | + guard url.isFileURL else { return nil } |
| 207 | + let name = (try? url.resourceValues(forKeys: [.volumeNameKey]).volumeName) ?? url.lastPathComponent |
| 208 | + guard !name.isEmpty else { return nil } |
| 209 | + return FinderSidebarItem(title: name, systemImage: "externaldrive.fill", tint: .secondary, action: .navigate(url)) |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +// MARK: - Finder Sidebar Item |
| 214 | +private struct FinderSidebarItem: Identifiable { |
| 215 | + let title: String |
| 216 | + let systemImage: String |
| 217 | + let tint: Color |
| 218 | + let action: FinderSidebarAction |
| 219 | + |
| 220 | + var id: String { |
| 221 | + "\(title)-\(helpText)" |
| 222 | + } |
| 223 | + |
| 224 | + var helpText: String { |
| 225 | + switch action { |
| 226 | + case .airDrop: |
| 227 | + return "AirDrop" |
| 228 | + case .network: |
| 229 | + return "Network" |
| 230 | + case .navigate(let url), .openIfExists(let url): |
| 231 | + return url.path |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + var shouldShow: Bool { |
| 236 | + switch action { |
| 237 | + case .airDrop, .network: |
| 238 | + return true |
| 239 | + case .navigate(let url), .openIfExists(let url): |
| 240 | + return FileManager.default.fileExists(atPath: url.path) |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + init(title: String, systemImage: String, tint: Color = .blue, action: FinderSidebarAction) { |
| 245 | + self.title = title |
| 246 | + self.systemImage = systemImage |
| 247 | + self.tint = tint |
| 248 | + self.action = action |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +// MARK: - Finder Sidebar Action |
| 253 | +private enum FinderSidebarAction { |
| 254 | + case airDrop |
| 255 | + case network |
| 256 | + case navigate(URL) |
| 257 | + case openIfExists(URL) |
| 258 | +} |
| 259 | + |
| 260 | +// MARK: - Finder Sidebar Tag |
| 261 | +private struct FinderSidebarTag: Identifiable { |
| 262 | + let title: String |
| 263 | + let color: Color |
| 264 | + |
| 265 | + var id: String { title } |
| 266 | + |
| 267 | + static let standard: [FinderSidebarTag] = [ |
| 268 | + FinderSidebarTag(title: "Red", color: .red), |
| 269 | + FinderSidebarTag(title: "Orange", color: .orange), |
| 270 | + FinderSidebarTag(title: "Yellow", color: .yellow), |
| 271 | + FinderSidebarTag(title: "Green", color: .green), |
| 272 | + FinderSidebarTag(title: "Blue", color: .blue), |
| 273 | + FinderSidebarTag(title: "Purple", color: .purple), |
| 274 | + FinderSidebarTag(title: "Gray", color: .gray) |
| 275 | + ] |
| 276 | +} |
0 commit comments