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

695 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}