Refine SFTP Open With menu

This commit is contained in:
Kaede Akino committed 2026-09-17 15:03:08 +08:00
1 parent e13f9e201f
commit 781dbcdfc9
4 files changed
+82 -21

No files matched your search

+2 -1
View File
@@ -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"
}
}
+34 -19
View File
@@ -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)
}
}
+29 -1
View File
@@ -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
}
}
+17
View File
@@ -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)