项目文件夹

文件
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

695 行
28 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import SwiftUI
import MacToolsPluginKit
// MARK: - ZshConfigEditorView
struct ZshConfigEditorView: View {
@ObservedObject var store: ZshConfigStore
private let localization: PluginLocalization
@State private var contentTab: ContentTab = .editor
@State private var activeSnippet: ZshSnippet? = nil
@State private var snippetInput: String = ""
@State private var showFileInfo: Bool = false
@State private var scrollToBottomID: Int = 0
@State private var isRunningSource = false
@State private var sourceResult: Bool? = nil
@State private var sourceResultToken = 0
init(
store: ZshConfigStore,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.store = store
self.localization = localization
}
private enum ContentTab: CaseIterable {
case editor
case quickInsert
var icon: String {
switch self {
case .editor: return "square.and.pencil"
case .quickInsert: return "bolt.fill"
}
}
func title(localization: PluginLocalization) -> String {
switch self {
case .editor:
return localization.string("editor.tab.editor", defaultValue: "编辑")
case .quickInsert:
return localization.string("editor.tab.quickInsert", defaultValue: "快速插入")
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
fileSelectorSection
contentTabBar
contentBody
}
}
// MARK: - File Selector
private var fileSelectorSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(
title: localization.string("editor.fileSelector.title", defaultValue: "配置文件"),
icon: "doc.text"
)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(ZshConfigFileType.allCases) { type in
fileTabButton(type: type)
}
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, 2)
}
.padding(.horizontal, -PluginSettingsTheme.Spacing.rowHorizontal)
}
}
private func fileTabButton(type: ZshConfigFileType) -> some View {
let isSelected = store.selectedType == type
let status = store.statusMap[type]
return Button {
store.select(type)
} label: {
HStack(spacing: 5) {
Circle()
.fill(fileStatusColor(status))
.frame(width: 7, height: 7)
Text(type.filename)
.font(.system(size: 12, weight: isSelected ? .semibold : .regular, design: .monospaced))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(isSelected
? Color.accentColor.opacity(0.15)
: PluginSettingsTheme.Palette.recessedControlBackground)
)
.overlay(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.stroke(isSelected ? Color.accentColor.opacity(0.4) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
// MARK: - Content Tab Bar
private var contentTabBar: some View {
HStack(spacing: 0) {
ForEach(ContentTab.allCases, id: \.self) { tab in
contentTabButton(tab)
}
}
.padding(3)
.background(
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card, style: .continuous)
.fill(PluginSettingsTheme.Palette.recessedControlBackground)
)
}
private func contentTabButton(_ tab: ContentTab) -> some View {
let isSelected = contentTab == tab
return Button {
withAnimation(.easeInOut(duration: 0.15)) {
contentTab = tab
}
} label: {
Label(tab.title(localization: localization), systemImage: tab.icon)
.font(.system(size: 12, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? .primary : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(
Group {
if isSelected {
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card - 2, style: .continuous)
.fill(PluginSettingsTheme.Palette.cardBackground)
.shadow(color: .black.opacity(0.06), radius: 2, y: 1)
}
}
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Content Body
@ViewBuilder
private var contentBody: some View {
switch contentTab {
case .editor:
editorBodySection
case .quickInsert:
snippetSection
}
}
// MARK: - Editor Tab
@ViewBuilder
private var editorBodySection: some View {
if let status = store.statusMap[store.selectedType] {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
fileInfoRow(status: status)
if status.exists {
editorField(isWritable: status.isWritable)
actionRow(status: status)
} else {
notExistBanner(status: status)
}
}
}
}
@ViewBuilder
private func fileInfoRow(status: ZshFileStatus) -> some View {
HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(store.selectedType.role(localization: localization))
.font(PluginSettingsTheme.Typography.rowTitle)
HStack(spacing: 4) {
if status.exists {
Text(status.formattedSize)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
if let date = status.modifiedDate {
Text("·").foregroundStyle(.secondary)
Text(date, style: .relative)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Text(localization.string("editor.fileInfo.modifiedAgo", defaultValue: "前修改"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
} else {
Text(localization.string("editor.fileInfo.missing", defaultValue: "文件不存在"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.15)) { showFileInfo.toggle() }
} label: {
Image(systemName: showFileInfo ? "info.circle.fill" : "info.circle")
.pluginSettingsRowIconStyle(showFileInfo ? Color.accentColor : .secondary)
}
.buttonStyle(.plain)
.help(localization.string("editor.fileInfo.help", defaultValue: "查看加载时机与推荐用途"))
}
.pluginSettingsListRowPadding()
.pluginSettingsCardBackground(.host)
if showFileInfo {
fileInfoPanel
}
}
private var fileInfoPanel: some View {
VStack(alignment: .leading, spacing: 6) {
infoRow(
label: localization.string("editor.fileInfo.whenLoaded", defaultValue: "加载时机"),
value: store.selectedType.whenLoaded(localization: localization)
)
PluginSettingsListDivider(leadingInset: 0, trailingInset: 0)
infoRow(
label: localization.string("editor.fileInfo.recommendedUse", defaultValue: "推荐用途"),
value: store.selectedType.recommendedUse(localization: localization)
)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.host)
.transition(.opacity.combined(with: .offset(y: -4)))
}
private func infoRow(label: String, value: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Text(label)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.frame(width: 60, alignment: .leading)
Text(value)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.primary)
}
}
private func editorField(isWritable: Bool) -> some View {
ZStack(alignment: .topLeading) {
if store.editingContent.isEmpty {
Text(localization.string(
"editor.emptyFile.placeholder",
defaultValue: "文件为空。可直接在此输入,或切换到「快速插入」标签添加常用配置。"
))
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.tertiary)
.padding(.top, 8)
.padding(.leading, 6)
.allowsHitTesting(false)
}
ZshSyntaxHighlightingEditor(
text: isWritable ? $store.editingContent : .constant(store.editingContent),
isEditable: isWritable,
onChange: { store.markEdited() },
scrollToBottomID: scrollToBottomID
)
.frame(minHeight: 200, maxHeight: .infinity)
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous)
.fill(PluginSettingsTheme.Palette.fieldBackground)
)
.overlay(
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous)
.stroke(PluginSettingsTheme.Palette.separator, lineWidth: PluginSettingsTheme.Stroke.hairline)
)
}
@ViewBuilder
private func actionRow(status: ZshFileStatus) -> some View {
HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
if status.isWritable {
Button {
store.saveCurrentFile()
} label: {
HStack(spacing: 4) {
if store.isBusy {
ProgressView().controlSize(.mini)
} else if store.lastSaveSucceeded {
Image(systemName: "checkmark")
.font(PluginSettingsTheme.Typography.statusBadge)
}
Text(saveButtonTitle)
}
.font(PluginSettingsTheme.Typography.controlLabel)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!store.hasUnsavedChanges || store.isBusy)
.help(localization.format(
"editor.action.save.help",
defaultValue: "保存前会自动备份为 %@.bak",
store.selectedType.filename
))
} else {
readOnlyBadge
}
Spacer()
Button {
store.openInExternalEditor(store.selectedType)
} label: {
Label(
localization.string("editor.action.openExternal", defaultValue: "在系统编辑器中打开"),
systemImage: "square.and.pencil"
)
.font(PluginSettingsTheme.Typography.controlLabel)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(localization.string("editor.action.openExternal.help", defaultValue: "在系统默认文本编辑器中打开此文件"))
}
if let error = store.saveError {
errorBanner(message: error)
}
if store.lastSaveSucceeded {
savedHintBanner
}
}
private func notExistBanner(status: ZshFileStatus) -> some View {
VStack(spacing: 14) {
Image(systemName: "doc.badge.plus")
.font(.system(size: PluginSettingsTheme.Size.emptyStateIcon + 4))
.foregroundStyle(.tertiary)
VStack(spacing: 5) {
Text(localization.string("editor.missingFile.title", defaultValue: "文件尚不存在"))
.font(PluginSettingsTheme.Typography.rowTitle)
Text(status.isWritable
? localization.format(
"editor.missingFile.createDescription",
defaultValue: "点击「创建文件」将在家目录生成 %@ 并填入说明注释。",
store.selectedType.filename
)
: localization.string(
"editor.missingFile.readOnlyDescription",
defaultValue: "家目录不可写,无法创建此文件。请检查磁盘权限。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if status.isWritable {
Button(localization.string("editor.action.createFile", defaultValue: "创建文件")) {
store.createCurrentFile()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(store.isBusy)
.padding(.top, 2)
}
if let error = store.saveError {
errorBanner(message: error)
}
}
.frame(maxWidth: .infinity, minHeight: 200)
.padding(PluginSettingsTheme.Spacing.rowHorizontal)
.pluginSettingsCardBackground(.host)
}
// MARK: - Quick Insert Tab
private var snippetSection: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
HStack(spacing: 6) {
Image(systemName: "arrow.right.circle")
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.secondary)
Text(localization.format(
"editor.quickInsert.target",
defaultValue: "插入到 %@",
store.selectedType.filename
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.15)) { contentTab = .editor }
} label: {
Text(localization.string("editor.quickInsert.switchToEditor", defaultValue: "切换到编辑器"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.recessed)
VStack(spacing: 0) {
ForEach(Array(snippets.enumerated()), id: \.element.id) { index, snippet in
VStack(spacing: 0) {
snippetRow(snippet: snippet)
if activeSnippet?.id == snippet.id {
snippetExpandedContent(snippet: snippet)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
if index < snippets.count - 1 {
PluginSettingsListDivider()
}
}
}
.pluginSettingsCardBackground(.host)
}
}
private func snippetRow(snippet: ZshSnippet) -> some View {
let isActive = activeSnippet?.id == snippet.id
return Button {
withAnimation(.easeInOut(duration: 0.15)) {
if isActive {
activeSnippet = nil
snippetInput = ""
} else {
activeSnippet = snippet
snippetInput = ""
}
}
} label: {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: snippet.icon)
.pluginSettingsRowIconStyle(isActive ? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(snippet.title)
.font(PluginSettingsTheme.Typography.rowTitle)
.foregroundStyle(.primary)
Text(snippet.description)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isActive ? 90 : 0))
.animation(.easeInOut(duration: 0.2), value: isActive)
}
.pluginSettingsListRowPadding()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func snippetExpandedContent(snippet: ZshSnippet) -> some View {
VStack(alignment: .leading, spacing: 10) {
TextField(snippet.placeholder, text: $snippetInput)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
.onSubmit { insertCurrentSnippet(snippet) }
if !snippetInput.isEmpty {
Text(snippet.buildContent(snippetInput))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous)
.fill(PluginSettingsTheme.Palette.recessedControlBackground)
)
}
HStack {
Spacer()
Button(localization.string("editor.quickInsert.insertAtEnd", defaultValue: "插入到末尾")) {
insertCurrentSnippet(snippet)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(snippetInput.isEmpty)
}
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.background(PluginSettingsTheme.Palette.cardBackground)
.overlay(alignment: .top) {
PluginSettingsListDivider(leadingInset: 0, trailingInset: 0)
}
}
// MARK: - Helper Views
private var readOnlyBadge: some View {
HStack(spacing: 4) {
Image(systemName: "lock")
.font(PluginSettingsTheme.Typography.statusBadge)
Text(localization.string("editor.readOnly", defaultValue: "只读"))
.font(PluginSettingsTheme.Typography.statusBadge)
}
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(PluginSettingsTheme.Palette.recessedControlBackground)
)
}
private func errorBanner(message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.red)
Text(message)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.red)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.pluginSettingsCardBackground(.recessed)
}
private var savedHintBanner: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.green)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 6) {
Text(localization.format(
"editor.savedHint.title",
defaultValue: "已保存(已备份为 %@.bak",
store.selectedType.filename
))
.font(PluginSettingsTheme.Typography.rowDescription)
if store.selectedType == .zshrc || store.selectedType == .zshenv {
HStack(spacing: 8) {
Image(systemName: "terminal")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.allowsHitTesting(false)
Text("source ~/\(store.selectedType.filename)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.allowsHitTesting(false)
Group {
if isRunningSource {
ProgressView()
.controlSize(.mini)
.frame(width: 16, height: 16)
} else if let result = sourceResult {
Image(systemName: result ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(result ? Color.green : Color.red)
.frame(width: 16, height: 16)
} else {
Button {
runSourceDirectly(for: store.selectedType)
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 14))
.foregroundStyle(Color.accentColor)
.frame(width: 16, height: 16)
}
.buttonStyle(.plain)
.fixedSize()
.contentShape(Circle())
}
}
.fixedSize()
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(.controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(PluginSettingsTheme.Palette.separator, lineWidth: PluginSettingsTheme.Stroke.hairline)
)
Text(localization.string("editor.savedHint.reloadDescription", defaultValue: "执行后无需重启终端即可生效。"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.pluginSettingsCardBackground(.recessed)
.onAppear {
sourceResult = nil
}
}
private func sectionHeader(title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
}
// MARK: - Helper Functions
private var snippets: [ZshSnippet] {
ZshSnippet.localizedSnippets(localization: localization)
}
private var saveButtonTitle: String {
if store.hasUnsavedChanges {
return localization.string("editor.action.saveUnsaved", defaultValue: "保存*")
}
if store.lastSaveSucceeded {
return localization.string("editor.action.saved", defaultValue: "已保存")
}
return localization.string("editor.action.save", defaultValue: "保存")
}
private func runSourceDirectly(for type: ZshConfigFileType) {
guard !isRunningSource else { return }
isRunningSource = true
sourceResult = nil
sourceResultToken += 1
let token = sourceResultToken
let filename = type.filename
Task {
let succeeded: Bool = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["--login", "-c", "source ~/\(filename)"]
do {
try process.run()
process.waitUntilExit()
continuation.resume(returning: process.terminationStatus == 0)
} catch {
continuation.resume(returning: false)
}
}
}
withAnimation(.easeInOut(duration: 0.2)) {
isRunningSource = false
sourceResult = succeeded
}
try? await Task.sleep(for: .seconds(3))
guard sourceResultToken == token else { return }
withAnimation(.easeInOut(duration: 0.2)) {
sourceResult = nil
}
}
}
private func runSourceInTerminal(for type: ZshConfigFileType) {
let script = """
tell application "Terminal"
activate
do script "source ~/\(type.filename)"
end tell
"""
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
try? process.run()
}
private func fileStatusColor(_ status: ZshFileStatus?) -> Color {
guard let status else { return .gray.opacity(0.4) }
if !status.exists { return .orange.opacity(0.6) }
if !status.isWritable { return .yellow }
return .green
}
private func insertCurrentSnippet(_ snippet: ZshSnippet) {
guard !snippetInput.isEmpty else { return }
store.appendSnippet(snippet.buildContent(snippetInput))
scrollToBottomID += 1
withAnimation(.easeInOut(duration: 0.15)) {
activeSnippet = nil
snippetInput = ""
contentTab = .editor
}
}
}