Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FileIsland.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
Presentation/Settings/SettingsView.swift,
Presentation/Settings/SettingsViewModel.swift,
Presentation/Settings/SettingsWindowController.swift,
Services/Clipboard/OutputClipboardWriting.swift,
Services/Localization/LocalizationController.swift,
Services/Output/OutputDirectorySelecting.swift,
Services/Output/OutputFolderBookmarkStore.swift,
Expand Down
13 changes: 13 additions & 0 deletions FileIsland/Domain/ConversionJob.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ struct ResultSummary: Equatable, Sendable {
let outputURLs: [URL]
let inputBytes: Int64
let outputBytes: Int64
let didCopyOutputToClipboard: Bool

init(
outputURLs: [URL],
inputBytes: Int64,
outputBytes: Int64,
didCopyOutputToClipboard: Bool = false
) {
self.outputURLs = outputURLs
self.inputBytes = inputBytes
self.outputBytes = outputBytes
self.didCopyOutputToClipboard = didCopyOutputToClipboard
}
}

struct UserFacingError: Error, Equatable, Sendable {
Expand Down
11 changes: 9 additions & 2 deletions FileIsland/Presentation/Island/IslandView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1102,17 +1102,24 @@ struct IslandView: View {
if let result = viewModel.lastVideoSplitResult {
return localization.string("%@ total", formatBytes(result.totalBytes))
}
let detail: String
guard summary.inputBytes > 0, summary.outputBytes < summary.inputBytes else {
return formatBytes(summary.outputBytes)
detail = formatBytes(summary.outputBytes)
return summary.didCopyOutputToClipboard
? "\(detail) · \(localization.string("Copied"))"
: detail
}
let percentage = Int(
(1 - Double(summary.outputBytes) / Double(summary.inputBytes)) * 100
)
return localization.string(
detail = localization.string(
"%@ · %d%% smaller",
formatBytes(summary.outputBytes),
percentage
)
return summary.didCopyOutputToClipboard
? "\(detail) · \(localization.string("Copied"))"
: detail
}

private func failureContent(_ error: UserFacingError) -> some View {
Expand Down
23 changes: 21 additions & 2 deletions FileIsland/Presentation/Island/IslandViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ final class IslandViewModel {
@ObservationIgnored
private let preferences: AppPreferences

@ObservationIgnored
private let outputClipboardWriter: any OutputClipboardWriting

@ObservationIgnored
private let capabilityResolver: ConversionCapabilityResolver

Expand Down Expand Up @@ -195,6 +198,7 @@ final class IslandViewModel {
outputDirectorySelector: any OutputDirectorySelecting = AppKitOutputDirectorySelector(),
outputFolderStore: OutputFolderBookmarkStore? = nil,
preferences: AppPreferences? = nil,
outputClipboardWriter: any OutputClipboardWriting = AppKitOutputClipboardWriter(),
capabilityResolver: ConversionCapabilityResolver = ConversionCapabilityResolver(),
thumbnailLoader: any ThumbnailLoading = QuickLookThumbnailLoader(),
imagePlanBuilder: ImageConversionPlanBuilder = ImageConversionPlanBuilder(),
Expand All @@ -219,6 +223,7 @@ final class IslandViewModel {
self.outputDirectorySelector = outputDirectorySelector
self.outputFolderStore = outputFolderStore
self.preferences = preferences ?? AppPreferences()
self.outputClipboardWriter = outputClipboardWriter
self.capabilityResolver = capabilityResolver
self.thumbnailLoader = thumbnailLoader
self.imagePlanBuilder = imagePlanBuilder
Expand Down Expand Up @@ -1117,12 +1122,19 @@ final class IslandViewModel {
let outputBytes = outputs.reduce(Int64(0)) { total, url in
total + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
}
let didCopyOutputToClipboard =
outputClipboardWriter.copySingleOutputIfEligible(
outputs,
inputCount: totalFiles,
isEnabled: preferences.copySingleOutputToClipboard
)
setState(
.success(
ResultSummary(
outputURLs: outputs,
inputBytes: totalInputBytes,
outputBytes: outputBytes
outputBytes: outputBytes,
didCopyOutputToClipboard: didCopyOutputToClipboard
)
)
)
Expand Down Expand Up @@ -1215,12 +1227,19 @@ final class IslandViewModel {
let outputBytes = result.outputURLs.reduce(Int64(0)) { total, url in
total + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
}
let didCopyOutputToClipboard =
outputClipboardWriter.copySingleOutputIfEligible(
result.outputURLs,
inputCount: scan.files.count,
isEnabled: preferences.copySingleOutputToClipboard
)
setState(
.success(
ResultSummary(
outputURLs: result.outputURLs,
inputBytes: totalInputBytes,
outputBytes: outputBytes
outputBytes: outputBytes,
didCopyOutputToClipboard: didCopyOutputToClipboard
)
)
)
Expand Down
9 changes: 9 additions & 0 deletions FileIsland/Presentation/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ struct SettingsView: View {
"Reveal converted files in Finder",
isOn: Bindable(viewModel.preferences).revealOutputOnCompletion
)
Toggle(
"Copy single converted files to Clipboard",
isOn: Bindable(viewModel.preferences).copySingleOutputToClipboard
)
Text(
"When one input creates one file, File Island copies its file reference for pasting into Finder or compatible apps."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Startup") {
Toggle(
Expand Down
30 changes: 30 additions & 0 deletions FileIsland/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,26 @@
}
}
},
"Copied" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已复制"
}
}
}
},
"Copy single converted files to Clipboard" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "单个文件转换完成后自动复制到剪贴板"
}
}
}
},
"Conversion" : {
"extractionState" : "stale",
"localizations" : {
Expand Down Expand Up @@ -2188,6 +2208,16 @@
}
}
},
"When one input creates one file, File Island copies its file reference for pasting into Finder or compatible apps." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "当一个输入生成一个文件时,File Island 会复制文件引用,可直接粘贴到访达或兼容的应用中。"
}
}
}
},
"Web Friendly" : {
"extractionState" : "stale",
"localizations" : {
Expand Down
44 changes: 44 additions & 0 deletions FileIsland/Services/Clipboard/OutputClipboardWriting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppKit
import Foundation

@MainActor
protocol OutputClipboardWriting {
@discardableResult
func copySingleOutputIfEligible(
_ outputURLs: [URL],
inputCount: Int,
isEnabled: Bool
) -> Bool
}

@MainActor
struct AppKitOutputClipboardWriter: OutputClipboardWriting {
private let pasteboard: NSPasteboard

init(pasteboard: NSPasteboard = .general) {
self.pasteboard = pasteboard
}

@discardableResult
func copySingleOutputIfEligible(
_ outputURLs: [URL],
inputCount: Int,
isEnabled: Bool
) -> Bool {
guard isEnabled,
inputCount == 1,
outputURLs.count == 1,
let outputURL = outputURLs.first,
outputURL.isFileURL,
let values = try? outputURL.resourceValues(
forKeys: [.isRegularFileKey, .isSymbolicLinkKey]
),
values.isRegularFile == true,
values.isSymbolicLink != true else {
return false
}

pasteboard.clearContents()
return pasteboard.writeObjects([outputURL as NSURL])
}
}
9 changes: 9 additions & 0 deletions FileIsland/Services/Settings/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ final class AppPreferences {
didSet { defaults.set(revealOutputOnCompletion, forKey: Keys.revealOutput) }
}

var copySingleOutputToClipboard: Bool {
didSet {
defaults.set(copySingleOutputToClipboard, forKey: Keys.copySingleOutputToClipboard)
}
}

var appLanguage: AppLanguage {
didSet { defaults.set(appLanguage.rawValue, forKey: Keys.appLanguage) }
}
Expand All @@ -42,6 +48,8 @@ final class AppPreferences {
) ?? .balanced
stripMetadataByDefault = defaults.object(forKey: Keys.stripMetadata) as? Bool ?? true
revealOutputOnCompletion = defaults.object(forKey: Keys.revealOutput) as? Bool ?? false
copySingleOutputToClipboard =
defaults.object(forKey: Keys.copySingleOutputToClipboard) as? Bool ?? false
appLanguage = AppLanguage(
rawValue: defaults.string(forKey: Keys.appLanguage) ?? ""
) ?? .system
Expand All @@ -53,6 +61,7 @@ final class AppPreferences {
static let defaultQuality = "conversion.defaultQuality"
static let stripMetadata = "conversion.stripMetadata"
static let revealOutput = "behavior.revealOutput"
static let copySingleOutputToClipboard = "behavior.copySingleOutputToClipboard"
static let islandOpacity = "appearance.islandOpacity"
static let appLanguage = "appearance.appLanguage"
}
Expand Down
32 changes: 31 additions & 1 deletion FileIslandTests/IslandViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -524,10 +524,18 @@ final class IslandViewModelTests: XCTestCase {
let outputDirectory = URL(fileURLWithPath: "/tmp/output", isDirectory: true)
let outputURL = outputDirectory.appendingPathComponent("photo.jpg")
let engine = StubConversionEngine(outputs: [outputURL])
let defaultsSuite = "IslandViewModelTests.Clipboard-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: defaultsSuite)!
defer { defaults.removePersistentDomain(forName: defaultsSuite) }
let preferences = AppPreferences(defaults: defaults)
preferences.copySingleOutputToClipboard = true
let clipboardWriter = RecordingOutputClipboardWriter()
let viewModel = IslandViewModel(
fileInspector: StubFileInspector(files: [file]),
conversionEngine: engine,
outputDirectorySelector: StubOutputDirectorySelector(url: outputDirectory)
outputDirectorySelector: StubOutputDirectorySelector(url: outputDirectory),
preferences: preferences,
outputClipboardWriter: clipboardWriter
)
viewModel.receiveDrop(urls: [file.url])
await waitForInspection(in: viewModel)
Expand All @@ -544,6 +552,10 @@ final class IslandViewModelTests: XCTestCase {
}
XCTAssertEqual(summary.outputURLs, [outputURL])
XCTAssertEqual(summary.inputBytes, 42)
XCTAssertTrue(summary.didCopyOutputToClipboard)
XCTAssertEqual(clipboardWriter.outputURLs, [outputURL])
XCTAssertEqual(clipboardWriter.inputCount, 1)
XCTAssertTrue(clipboardWriter.isEnabled)
let plan = await engine.lastPlan
guard case .chosenDirectory(let directory, _) = plan?.outputPolicy else {
return XCTFail("Expected chosen output directory")
Expand Down Expand Up @@ -1538,6 +1550,24 @@ private struct FailingPresetCatalogLoader: PresetCatalogLoading {
}
}

@MainActor
private final class RecordingOutputClipboardWriter: OutputClipboardWriting {
private(set) var outputURLs: [URL] = []
private(set) var inputCount = 0
private(set) var isEnabled = false

func copySingleOutputIfEligible(
_ outputURLs: [URL],
inputCount: Int,
isEnabled: Bool
) -> Bool {
self.outputURLs = outputURLs
self.inputCount = inputCount
self.isEnabled = isEnabled
return true
}
}

@MainActor
private struct StubOutputDirectorySelector: OutputDirectorySelecting {
let url: URL?
Expand Down
Loading