Refine SFTP Open With menu
This commit is contained in:
1 parent
e13f9e201f
commit
781dbcdfc9
4 files changed
+82
-21
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user