From d16b1442c12ab3ec4413d7cf51e4cb9630a16511 Mon Sep 17 00:00:00 2001 From: TREAFREE <153535159+TREAFREE@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:45:47 +0800 Subject: [PATCH] Add optional single-output clipboard copy --- FileIsland.xcodeproj/project.pbxproj | 1 + FileIsland/Domain/ConversionJob.swift | 13 +++ .../Presentation/Island/IslandView.swift | 11 +- .../Presentation/Island/IslandViewModel.swift | 23 +++- .../Presentation/Settings/SettingsView.swift | 9 ++ FileIsland/Resources/Localizable.xcstrings | 30 ++++++ .../Clipboard/OutputClipboardWriting.swift | 44 ++++++++ .../Services/Settings/AppPreferences.swift | 9 ++ FileIslandTests/IslandViewModelTests.swift | 32 +++++- .../OutputClipboardWriterTests.swift | 100 ++++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- docs/USER_GUIDE.md | 4 +- docs/USER_GUIDE.zh-CN.md | 4 +- docs/releases/v0.3.1.md | 2 + 15 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 FileIsland/Services/Clipboard/OutputClipboardWriting.swift create mode 100644 FileIslandTests/OutputClipboardWriterTests.swift diff --git a/FileIsland.xcodeproj/project.pbxproj b/FileIsland.xcodeproj/project.pbxproj index 7a47af1..d470167 100644 --- a/FileIsland.xcodeproj/project.pbxproj +++ b/FileIsland.xcodeproj/project.pbxproj @@ -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, diff --git a/FileIsland/Domain/ConversionJob.swift b/FileIsland/Domain/ConversionJob.swift index b4afb32..2429cd8 100644 --- a/FileIsland/Domain/ConversionJob.swift +++ b/FileIsland/Domain/ConversionJob.swift @@ -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 { diff --git a/FileIsland/Presentation/Island/IslandView.swift b/FileIsland/Presentation/Island/IslandView.swift index 51a0813..7f38b85 100644 --- a/FileIsland/Presentation/Island/IslandView.swift +++ b/FileIsland/Presentation/Island/IslandView.swift @@ -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 { diff --git a/FileIsland/Presentation/Island/IslandViewModel.swift b/FileIsland/Presentation/Island/IslandViewModel.swift index 68edef2..d3a2c7f 100644 --- a/FileIsland/Presentation/Island/IslandViewModel.swift +++ b/FileIsland/Presentation/Island/IslandViewModel.swift @@ -59,6 +59,9 @@ final class IslandViewModel { @ObservationIgnored private let preferences: AppPreferences + @ObservationIgnored + private let outputClipboardWriter: any OutputClipboardWriting + @ObservationIgnored private let capabilityResolver: ConversionCapabilityResolver @@ -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(), @@ -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 @@ -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 ) ) ) @@ -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 ) ) ) diff --git a/FileIsland/Presentation/Settings/SettingsView.swift b/FileIsland/Presentation/Settings/SettingsView.swift index fda190c..12741aa 100644 --- a/FileIsland/Presentation/Settings/SettingsView.swift +++ b/FileIsland/Presentation/Settings/SettingsView.swift @@ -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( diff --git a/FileIsland/Resources/Localizable.xcstrings b/FileIsland/Resources/Localizable.xcstrings index 8e37f0b..1894330 100644 --- a/FileIsland/Resources/Localizable.xcstrings +++ b/FileIsland/Resources/Localizable.xcstrings @@ -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" : { @@ -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" : { diff --git a/FileIsland/Services/Clipboard/OutputClipboardWriting.swift b/FileIsland/Services/Clipboard/OutputClipboardWriting.swift new file mode 100644 index 0000000..21542ea --- /dev/null +++ b/FileIsland/Services/Clipboard/OutputClipboardWriting.swift @@ -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]) + } +} diff --git a/FileIsland/Services/Settings/AppPreferences.swift b/FileIsland/Services/Settings/AppPreferences.swift index c3f8dfd..baf84d0 100644 --- a/FileIsland/Services/Settings/AppPreferences.swift +++ b/FileIsland/Services/Settings/AppPreferences.swift @@ -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) } } @@ -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 @@ -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" } diff --git a/FileIslandTests/IslandViewModelTests.swift b/FileIslandTests/IslandViewModelTests.swift index 94ef254..e249b28 100644 --- a/FileIslandTests/IslandViewModelTests.swift +++ b/FileIslandTests/IslandViewModelTests.swift @@ -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) @@ -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") @@ -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? diff --git a/FileIslandTests/OutputClipboardWriterTests.swift b/FileIslandTests/OutputClipboardWriterTests.swift new file mode 100644 index 0000000..54e3df2 --- /dev/null +++ b/FileIslandTests/OutputClipboardWriterTests.swift @@ -0,0 +1,100 @@ +import AppKit +import Foundation +import Testing +@testable import FileIsland + +@MainActor +struct OutputClipboardWriterTests { + @Test + func copiesOneLocalOutputWhenEnabled() throws { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("OutputClipboardWriterTests-\(UUID().uuidString)") + ) + defer { pasteboard.clearContents() } + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("converted-\(UUID().uuidString).jpg") + try Data([0x01]).write(to: outputURL) + defer { try? FileManager.default.removeItem(at: outputURL) } + + let writer = AppKitOutputClipboardWriter(pasteboard: pasteboard) + + let copied = writer.copySingleOutputIfEligible( + [outputURL], + inputCount: 1, + isEnabled: true + ) + + #expect(copied) + let objects = try #require( + pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [NSURL] + ) + let copiedURL = try #require(objects.first as URL?) + #expect(copiedURL.standardizedFileURL == outputURL.standardizedFileURL) + } + + @Test + func leavesClipboardUntouchedWhenAutomaticCopyIsDisabled() throws { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("OutputClipboardWriterTests-\(UUID().uuidString)") + ) + defer { pasteboard.clearContents() } + pasteboard.clearContents() + pasteboard.setString("keep me", forType: .string) + let writer = AppKitOutputClipboardWriter(pasteboard: pasteboard) + + let copied = writer.copySingleOutputIfEligible( + [URL(fileURLWithPath: "/tmp/output.jpg")], + inputCount: 1, + isEnabled: false + ) + + #expect(copied == false) + #expect(pasteboard.string(forType: .string) == "keep me") + } + + @Test(arguments: [ + (inputCount: 2, outputCount: 1), + (inputCount: 1, outputCount: 2), + (inputCount: 0, outputCount: 1), + ]) + func rejectsNonSingleFileResults(inputCount: Int, outputCount: Int) { + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("OutputClipboardWriterTests-\(UUID().uuidString)") + ) + defer { pasteboard.clearContents() } + pasteboard.clearContents() + pasteboard.setString("keep me", forType: .string) + let writer = AppKitOutputClipboardWriter(pasteboard: pasteboard) + let outputs = (0..