From 781dbcdfc96f02a56f97f5cf9b42ffe89c572e87 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 17 Sep 2026 15:03:08 +0800 Subject: [PATCH] Refine SFTP Open With menu --- Frameworks/Resources/Localizable.xcstrings | 3 +- TFM/Features/SFTP/SFTPOpenWithMenu.swift | 53 ++++++++++++++-------- TFM/Platform/SFTPApplicationOpener.swift | 30 +++++++++++- TFMTests/SFTPApplicationOpenerTests.swift | 17 +++++++ 4 files changed, 82 insertions(+), 21 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index baf6fdd9..c716e51c 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -6,6 +6,7 @@ "Open With" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式"}}}}, "Open With…" : {"extractionState":"extracted","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开方式…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開方式…"}}}}, "Other…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"其他…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"其他…"}}}}, + "App Store…" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"App Store…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"App Store…"}}}}, "%@ (default)" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%@(默认)"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"%@(預設)"}}}}, "No installed app can open this file." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"没有已安装的应用可以打开此文件。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"沒有已安裝的App可以打開此檔案。"}}}}, "Error Details" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"错误详情"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"錯誤詳細資訊"}}}}, @@ -28461,4 +28462,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/TFM/Features/SFTP/SFTPOpenWithMenu.swift b/TFM/Features/SFTP/SFTPOpenWithMenu.swift index 3fbdc171..08a648a9 100644 --- a/TFM/Features/SFTP/SFTPOpenWithMenu.swift +++ b/TFM/Features/SFTP/SFTPOpenWithMenu.swift @@ -3,34 +3,49 @@ import SwiftUI struct SFTPOpenWithMenu: View { let filename: String let open: (URL) -> Void - @State private var applications: [SFTPFileApplication] = [] var body: some View { Menu("Open With") { - ForEach(applications) { application in - Button { - open(application.url) - } label: { - HStack { - if let icon = application.icon { - Image(nsImage: icon).resizable().frame(width: 16, height: 16) - } - if application.isDefault { - Text("\(application.name) (default)") - } else { - Text(verbatim: application.name) - } - } - } + let applications = SFTPApplicationOpener.applications(for: filename) + if let defaultApplication = applications.first(where: \.isDefault) { + applicationButton(defaultApplication) + Divider() } - if !applications.isEmpty { Divider() } + let otherApplications = applications.filter { !$0.isDefault } + ForEach(otherApplications) { application in + applicationButton(application) + } + if !otherApplications.isEmpty { Divider() } + Button("App Store…") { + SFTPApplicationOpener.openAppStore(for: filename) + } + .disabled(SFTPApplicationOpener.appStoreSearchURL(for: filename) == nil) Button("Other…") { Task { if let application = await SFTPApplicationOpener.chooseApplication() { open(application) } } } } - // Context menu content appears only when requested, not while rendering file rows. - .onAppear { applications = SFTPApplicationOpener.applications(for: filename) } + } + + private func applicationButton(_ application: SFTPFileApplication) -> some View { + Button { + open(application.url) + } label: { + if let icon = application.icon { + Label { + Text(verbatim: title(for: application)) + } icon: { + Image(nsImage: icon) + } + } else { + Text(verbatim: title(for: application)) + } + } + } + + private func title(for application: SFTPFileApplication) -> String { + guard application.isDefault else { return application.name } + return String.localizedStringWithFormat(String(localized: "%@ (default)"), application.name) } } diff --git a/TFM/Platform/SFTPApplicationOpener.swift b/TFM/Platform/SFTPApplicationOpener.swift index 3fe884c4..7deddfc6 100644 --- a/TFM/Platform/SFTPApplicationOpener.swift +++ b/TFM/Platform/SFTPApplicationOpener.swift @@ -12,6 +12,8 @@ struct SFTPFileApplication: Identifiable { @MainActor enum SFTPApplicationOpener { + private static let menuIconSize = NSSize(width: 16, height: 16) + static func applications(for filename: String) -> [SFTPFileApplication] { let ext = (filename as NSString).pathExtension guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return [] } @@ -35,7 +37,8 @@ enum SFTPApplicationOpener { return (urls + (defaultURL.map { [$0] } ?? [])).compactMap { url in let url = url.standardizedFileURL guard seen.insert(url).inserted else { return nil } - return SFTPFileApplication(url: url, name: name(url), icon: icon(url), isDefault: url == preferred) + return SFTPFileApplication( + url: url, name: name(url), icon: menuIcon(icon(url)), isDefault: url == preferred) }.sorted { lhs, rhs in if lhs.isDefault != rhs.isDefault { return lhs.isDefault } let order = lhs.name.localizedStandardCompare(rhs.name) @@ -56,8 +59,33 @@ enum SFTPApplicationOpener { return await panel.begin() == .OK ? panel.url : nil } + static func appStoreSearchURL(for filename: String) -> URL? { + let ext = (filename as NSString).pathExtension + guard !ext.isEmpty else { return nil } + var components = URLComponents() + components.scheme = "macappstore" + components.host = "search.itunes.apple.com" + components.path = "/WebObjects/MZSearch.woa/wa/search" + components.queryItems = [ + URLQueryItem(name: "term", value: ext), + URLQueryItem(name: "media", value: "software"), + ] + return components.url + } + + static func openAppStore(for filename: String) { + guard let url = appStoreSearchURL(for: filename) else { return } + NSWorkspace.shared.open(url) + } + static func open(_ url: URL, with application: URL) async throws { _ = try await NSWorkspace.shared.open( [url], withApplicationAt: application, configuration: NSWorkspace.OpenConfiguration()) } + + private static func menuIcon(_ icon: NSImage?) -> NSImage? { + guard let icon = icon?.copy() as? NSImage else { return nil } + icon.size = menuIconSize + return icon + } } diff --git a/TFMTests/SFTPApplicationOpenerTests.swift b/TFMTests/SFTPApplicationOpenerTests.swift index 8b21c989..d79a64d8 100644 --- a/TFMTests/SFTPApplicationOpenerTests.swift +++ b/TFMTests/SFTPApplicationOpenerTests.swift @@ -24,6 +24,23 @@ struct SFTPApplicationOpenerTests { #expect(result.first?.url == preferred) } + @Test func applicationIconsUseNativeMenuDimensions() throws { + let icon = NSImage(size: NSSize(width: 128, height: 128)) + let result = SFTPApplicationOpener.applications( + urls: [URL(filePath: "/Applications/Editor.app")], defaultURL: nil, name: { _ in "Editor" }, + icon: { _ in icon }) + #expect(try #require(result.first?.icon).size == NSSize(width: 16, height: 16)) + #expect(icon.size == NSSize(width: 128, height: 128)) + } + + @Test func appStoreSearchUsesTheFilenameExtension() throws { + let url = try #require(SFTPApplicationOpener.appStoreSearchURL(for: "Quarterly Report.pages")) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.scheme == "macappstore") + #expect(components.queryItems?.first(where: { $0.name == "term" })?.value == "pages") + #expect(SFTPApplicationOpener.appStoreSearchURL(for: "README") == nil) + } + @Test func missingApplicationPropagatesLaunchFailure() async throws { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)