Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
145 lines
5.1 KiB
Swift
145 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|