import AppKit import Foundation /// The Control wheel: things you can do with a file that are not conversions. enum ActionID: String, CaseIterable, Identifiable, Codable { case airdrop, float, copy, move, copyPath, terminal, revealInFinder var id: String { rawValue } var title: String { switch self { case .airdrop: return "AirDrop" case .float: return "Float" case .copy: return "Copy" case .move: return "Move" case .copyPath: return "Copy Path" case .terminal: return "Terminal" case .revealInFinder: return "Reveal" } } var symbol: String { switch self { case .airdrop: return "airdrop" case .float: return "macwindow.on.rectangle" case .copy: return "doc.on.doc" case .move: return "arrow.right.doc.on.clipboard" case .copyPath: return "text.cursor" case .terminal: return "terminal" case .revealInFinder: return "folder" } } } enum ActionError: LocalizedError { case needsSingleFile case noDestination case unavailable(String) var errorDescription: String? { switch self { case .needsSingleFile: return "This action works on one file at a time." case .noDestination: return "No destination folder was chosen." case .unavailable(let reason): return reason } } } /// File actions never touch the file's contents, and never use a conversion /// slot — matching Tangerine's behaviour. enum FileActions { @MainActor static func perform(_ action: ActionID, urls: [URL], settings: Settings = .shared) async throws { guard !urls.isEmpty else { throw ActionError.noDestination } switch action { case .airdrop: try performAirDrop(urls: urls) case .float: FloatPanel.shared.show(urls: urls) case .copy: if let destination = try await chooseDestination() { try transfer(urls: urls, to: destination, moving: false) } case .move: if let destination = try await chooseDestination() { try transfer(urls: urls, to: destination, moving: true) } case .copyPath: copyPaths(urls: urls) case .terminal: try openTerminal(urls: urls) case .revealInFinder: NSWorkspace.shared.activateFileViewerSelecting(urls) } } @MainActor private static func performAirDrop(urls: [URL]) throws { guard let service = NSSharingService(named: .sendViaAirDrop) else { throw ActionError.unavailable("AirDrop is not available on this Mac.") } guard service.canPerform(withItems: urls) else { throw ActionError.unavailable("AirDrop cannot send this selection.") } service.perform(withItems: urls) } @MainActor private static func chooseDestination() async throws -> URL? { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.prompt = "Choose" panel.message = "Choose a destination folder" panel.directoryURL = Settings.shared.resolvedOutputFolder NSApp.activate(ignoringOtherApps: true) return panel.runModal() == .OK ? panel.url : nil } private static func transfer(urls: [URL], to destination: URL, moving: Bool) throws { let manager = FileManager.default for url in urls { var target = destination.appendingPathComponent(url.lastPathComponent) target = OutputNaming.availableURL(for: target) do { if moving { try manager.moveItem(at: url, to: target) } else { try manager.copyItem(at: url, to: target) } } catch { throw ActionError.unavailable("Could not \(moving ? "move" : "copy") \(url.lastPathComponent): \(error.localizedDescription)") } } } @MainActor private static func copyPaths(urls: [URL]) { let text = urls.map(\.path).joined(separator: "\n") let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) Notifier.flash( title: urls.count == 1 ? "Path copied" : "\(urls.count) paths copied", body: text.count > 120 ? String(text.prefix(120)) + "…" : text ) } @MainActor private static func openTerminal(urls: [URL]) throws { let directory = urls[0].deletingLastPathComponent() let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app") let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.open([directory], withApplicationAt: terminal, configuration: configuration) { _, error in if let error { DispatchQueue.main.async { Notifier.error("Terminal", error.localizedDescription) } } } } }