Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

253 lines
11 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
/// Settings view for the Finder right-click menu, shown via the plugin's
/// `PluginConfiguration`. It writes the shared `RightClickConfiguration` (read by
/// the sandboxed extension through a read-only file exception), so the user
/// controls which items appear when right-clicking in Finder. Changes take effect
/// on the next right-click — the extension reads the config on every menu build.
struct RightClickMenuSettingsView: View {
@State private var configuration = RightClickConfigurationStore.load()
let localization: PluginLocalization
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
self.localization = localization
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
directorySection
copySection
openWithSection
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.onChange(of: configuration) { _, newValue in
saveConfiguration(newValue)
}
}
private func saveConfiguration(_ newValue: RightClickConfiguration) {
var configuration = newValue
let currentConfiguration = RightClickConfigurationStore.load()
configuration.menuEnabled = currentConfiguration.menuEnabled
configuration.preferredLanguages = currentConfiguration.preferredLanguages
RightClickConfigurationStore.save(configuration)
}
private var directorySection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(title: localization.string("settings.directory.title", defaultValue: "目录操作"), icon: "folder")
VStack(spacing: 0) {
toggleRow(title: localization.string("settings.newFolder.title", defaultValue: "新建文件夹"), isOn: $configuration.newFolder)
PluginSettingsListDivider()
toggleRow(
title: localization.string("settings.newFile.title", defaultValue: "新建文件"),
description: localization.string("settings.newFile.description", defaultValue: "支持 .txt / .md / .json"),
isOn: $configuration.newFile
)
PluginSettingsListDivider()
toggleRow(title: localization.string("settings.openTerminal.title", defaultValue: "在终端打开"), isOn: $configuration.openInTerminal)
}
.pluginSettingsCardBackground(.host)
}
}
private var copySection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(title: localization.string("settings.copy.title", defaultValue: "复制菜单项"), icon: "doc.on.doc")
VStack(spacing: 0) {
toggleRow(title: localization.string("settings.copyFileName.title", defaultValue: "复制文件名"), isOn: $configuration.copyFileName)
PluginSettingsListDivider()
toggleRow(title: localization.string("settings.copyAbsolutePath.title", defaultValue: "复制绝对路径"), isOn: $configuration.copyAbsolutePath)
PluginSettingsListDivider()
toggleRow(title: localization.string("settings.copyRelativePath.title", defaultValue: "复制相对路径"), isOn: $configuration.copyRelativePath)
PluginSettingsListDivider()
toggleRow(
title: localization.string("settings.copyShellEscapedPath.title", defaultValue: "复制转义路径"),
description: localization.string("settings.copyShellEscapedPath.description", defaultValue: "适合终端命令"),
isOn: $configuration.copyShellEscapedPath
)
PluginSettingsListDivider()
toggleRow(title: localization.string("settings.copyFileURL.title", defaultValue: "复制 file:// 链接"), isOn: $configuration.copyFileURL)
}
.pluginSettingsCardBackground(.host)
}
}
private var openWithSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
HStack {
sectionHeader(title: localization.string("settings.openWith.title", defaultValue: "用应用打开"), icon: "app.badge")
Spacer()
Button {
addApp()
} label: {
Label(localization.string("settings.addApp.button", defaultValue: "添加"), systemImage: "plus")
.font(PluginSettingsTheme.Typography.controlLabel)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
VStack(spacing: 0) {
if configuration.openWithApps.isEmpty {
emptyOpenWithView
} else {
ForEach($configuration.openWithApps) { $app in
RightClickOpenWithAppRow(app: $app) {
configuration.openWithApps.removeAll { $0.id == app.id }
}
.environment(\.rightClickLocalization, localization)
if app.id != configuration.openWithApps.last?.id {
PluginSettingsListDivider()
}
}
}
}
.pluginSettingsCardBackground(.host)
Text(localization.string(
"settings.openWith.footnote",
defaultValue: "扩展名留空表示对所有文件显示,多个扩展名用逗号分隔。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var emptyOpenWithView: some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: "app.dashed")
.pluginSettingsRowIconStyle()
Text(localization.string("settings.openWith.empty", defaultValue: "暂无应用"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Spacer()
}
.pluginSettingsListRowPadding()
}
private func toggleRow(
title: String,
description: String? = nil,
isOn: Binding<Bool>
) -> some View {
HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(title)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
if let description {
Text(description)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
Toggle("", isOn: isOn)
.toggleStyle(.switch)
.labelsHidden()
.controlSize(.small)
}
.pluginSettingsListRowPadding(interactive: true)
}
private func sectionHeader(title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
}
/// Let the user pick a `.app` bundle and append it to the list.
private func addApp() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.application]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.prompt = localization.string("settings.openPanel.prompt", defaultValue: "选择")
guard panel.runModal() == .OK, let url = panel.url else { return }
// displayName is localized and usually extension-free; strip only a
// trailing ".app" rather than a global replace.
let displayName = FileManager.default.displayName(atPath: url.path)
let name = displayName.hasSuffix(".app") ? String(displayName.dropLast(4)) : displayName
configuration.openWithApps.append(
RightClickOpenWithApp(name: name, appPath: url.path, fileExtensions: [])
)
}
}
/// One row in the "open with" list: app name + path, an editable comma-separated
/// extension filter, and a delete button.
private struct RightClickOpenWithAppRow: View {
@Binding var app: RightClickOpenWithApp
let onDelete: () -> Void
@Environment(\.rightClickLocalization) private var localization
@State private var extensionsText: String
init(app: Binding<RightClickOpenWithApp>, onDelete: @escaping () -> Void) {
_app = app
self.onDelete = onDelete
_extensionsText = State(
initialValue: app.wrappedValue.fileExtensions.joined(separator: ", ")
)
}
var body: some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(app.name)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Text(app.appPath)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
TextField(localization.string("settings.extensions.placeholder", defaultValue: "扩展名"), text: $extensionsText)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 108, idealWidth: 120, maxWidth: 140)
.onChange(of: extensionsText) { _, newValue in
// pathExtension values are dotless; strip a leading "." so a
// user entering ".txt" still matches.
app.fileExtensions = newValue
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.map { $0.hasPrefix(".") ? String($0.dropFirst()) : $0 }
.filter { !$0.isEmpty }
}
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
.foregroundStyle(.red)
.frame(
width: PluginSettingsTheme.Size.controlHeight,
height: PluginSettingsTheme.Size.controlHeight
)
}
.buttonStyle(.plain)
.help(localization.string("settings.deleteApp.help", defaultValue: "删除应用"))
}
.pluginSettingsListRowPadding(interactive: true)
}
}
private struct RightClickLocalizationEnvironmentKey: EnvironmentKey {
static let defaultValue = PluginLocalization(bundle: .main)
}
private extension EnvironmentValues {
var rightClickLocalization: PluginLocalization {
get { self[RightClickLocalizationEnvironmentKey.self] }
set { self[RightClickLocalizationEnvironmentKey.self] = newValue }
}
}