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

1342 lines
56 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
struct HomebrewDetailView: View {
@ObservedObject var controller: HomebrewController
private let localization: PluginLocalization
private let showsHeader: Bool
private let contentPadding: CGFloat
private let minimumContentHeight: CGFloat
@State private var activeTab: BrewTabSection = .installed
@State private var installedFilter: BrewPackageFilter = .all
@State private var localSearchText = ""
@State private var onlineSearchText = ""
@State private var newTapName = ""
@State private var selectedInstalledPkg: BrewPackage?
@State private var selectedSearchPkg: BrewPackage?
@State private var onlinePackageDetail: BrewPackage?
@State private var isFetchingOnlineDetail = false
@State private var customBrewPath = ""
@State private var pendingAction: HomebrewPendingAction?
@State private var didRequestInitialScan = false
enum BrewTabSection: String, CaseIterable, Identifiable {
case installed
case search
case taps
case diagnostics
var id: String { rawValue }
func title(localization: PluginLocalization) -> String {
switch self {
case .installed: return localization.string("detail.tabs.installed", defaultValue: "已安装")
case .search: return localization.string("detail.tabs.search", defaultValue: "搜索")
case .taps: return localization.string("detail.tabs.taps", defaultValue: "软件源")
case .diagnostics: return localization.string("detail.tabs.diagnostics", defaultValue: "诊断")
}
}
var icon: String {
switch self {
case .installed: return "shippingbox.fill"
case .search: return "magnifyingglass"
case .taps: return "square.stack.3d.up.fill"
case .diagnostics: return "wrench.and.screwdriver.fill"
}
}
}
enum BrewPackageFilter: String, CaseIterable, Identifiable {
case all
case formula
case cask
var id: String { rawValue }
func title(localization: PluginLocalization) -> String {
switch self {
case .all: return localization.string("detail.filter.all", defaultValue: "全部")
case .formula: return localization.string("detail.filter.formula", defaultValue: "Formula")
case .cask: return localization.string("detail.filter.cask", defaultValue: "Cask")
}
}
}
init(
controller: HomebrewController,
localization: PluginLocalization = PluginLocalization(bundle: .main),
showsHeader: Bool = true,
contentPadding: CGFloat = 16,
minimumContentHeight: CGFloat = 450
) {
self.controller = controller
self.localization = localization
self.showsHeader = showsHeader
self.contentPadding = contentPadding
self.minimumContentHeight = minimumContentHeight
}
var body: some View {
VStack(spacing: 0) {
if showsHeader {
header
.padding(.horizontal, contentPadding)
.padding(.top, contentPadding)
}
if !controller.isBrewAvailable {
pathNotFoundView
} else {
VStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
tabBar
statusSummaryBar
}
.padding(.horizontal, contentPadding)
.padding(.vertical, PluginSettingsTheme.Spacing.sectionHeaderContent)
Divider()
// Main Content Area
GeometryReader { _ in
VStack(spacing: 0) {
Group {
switch activeTab {
case .installed:
installedTabContent
case .search:
searchTabContent
case .taps:
tapsTabContent
case .diagnostics:
diagnosticsTabContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Console Output Drawer
if !controller.logs.isEmpty {
consoleDrawer
.frame(height: 140)
.transition(.move(edge: .bottom))
}
}
}
}
}
.frame(minHeight: minimumContentHeight)
.onAppear {
customBrewPath = controller.brewPath
requestInitialScanIfNeeded()
}
.onChange(of: controller.brewPath) { _, newPath in
customBrewPath = newPath
}
.onChange(of: controller.isBrewAvailable) { _, _ in
requestInitialScanIfNeeded()
}
.onChange(of: controller.isBusy) { _, isBusy in
if !isBusy {
requestInitialScanIfNeeded()
}
}
.confirmationDialog(
pendingAction?.title(localization: localization)
?? localization.string("detail.confirm.defaultTitle", defaultValue: "确认操作"),
isPresented: Binding(
get: { pendingAction != nil },
set: { if !$0 { pendingAction = nil } }
),
titleVisibility: .visible
) {
if let pendingAction {
Button(pendingAction.buttonTitle(localization: localization), role: pendingAction.role) {
perform(pendingAction)
self.pendingAction = nil
}
}
Button(localization.string("detail.confirm.cancel", defaultValue: "取消"), role: .cancel) {
pendingAction = nil
}
} message: {
if let pendingAction {
Text(pendingAction.message(localization: localization))
}
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(localization.string("detail.title", defaultValue: "Homebrew Manager"))
.font(PluginSettingsTheme.Typography.pageTitle)
Text(localization.string("detail.subtitle", defaultValue: "Manage Homebrew packages, applications, and repositories visually"))
.font(PluginSettingsTheme.Typography.pageDescription)
.foregroundStyle(.secondary)
}
Spacer()
if controller.isBusy {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text(controller.currentOperationName)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.pluginSettingsCardBackground(.recessed)
}
}
}
private var tabBar: some View {
HStack {
Spacer(minLength: 0)
Picker(localization.string("detail.tabs.picker", defaultValue: "视图"), selection: $activeTab) {
ForEach(BrewTabSection.allCases) { section in
Label(section.title(localization: localization), systemImage: section.icon)
.tag(section)
}
}
.pickerStyle(.segmented)
.controlSize(.large)
.labelsHidden()
.frame(minWidth: 500, idealWidth: 560, maxWidth: 620)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
}
private var statusSummaryBar: some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
statusMetric(
title: localization.string("detail.status.installed", defaultValue: "已安装"),
value: "\(controller.installedPackages.count)",
systemImage: "shippingbox.fill",
color: Color(nsColor: .secondaryLabelColor)
)
statusMetric(
title: localization.string("detail.status.outdated", defaultValue: "可更新"),
value: "\(controller.outdatedPackages.count)",
systemImage: "arrow.up.circle.fill",
color: controller.outdatedPackages.isEmpty
? Color(nsColor: .secondaryLabelColor)
: .orange
)
Spacer(minLength: 12)
HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
Image(systemName: "terminal")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(controller.brewPath.isEmpty
? localization.string("detail.status.brewPathMissing", defaultValue: "未配置 brew")
: controller.brewPath)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
.frame(minWidth: 160, maxWidth: 360, alignment: .trailing)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.recessed)
}
private func statusMetric(
title: String,
value: String,
systemImage: String,
color: Color
) -> some View {
HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
Image(systemName: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
Text(title)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Text(value)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.primary)
}
}
private func refreshButton(title: String) -> some View {
Button {
controller.scanAll()
} label: {
Label(title, systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!controller.isBrewAvailable || controller.isBusy)
.help(localization.string("detail.refresh.help", defaultValue: "刷新 Homebrew 状态"))
}
private func sectionHeader(title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
}
// MARK: - Tab Contents
private var installedTabContent: some View {
let filtered = filteredInstalledPackages
return HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
VStack(spacing: 0) {
installedToolbar
PluginSettingsListDivider(leadingInset: 0, trailingInset: 0)
if filtered.isEmpty {
installedEmptyState
} else {
installedPackageList(filtered)
}
}
.pluginSettingsCardBackground(.host)
.frame(width: 340)
.frame(maxHeight: .infinity)
installedDetailPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent)
.padding(.horizontal, contentPadding)
.padding(.bottom, contentPadding)
}
private var filteredInstalledPackages: [BrewPackage] {
controller.installedPackages.filter { pkg in
let matchesText = localSearchText.isEmpty
|| pkg.name.localizedCaseInsensitiveContains(localSearchText)
|| pkg.desc.localizedCaseInsensitiveContains(localSearchText)
let matchesFilter = installedFilter == .all
|| (installedFilter == .formula && !pkg.isCask)
|| (installedFilter == .cask && pkg.isCask)
return matchesText && matchesFilter
}
}
private var installedToolbar: some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
TextField(localization.string("detail.search.placeholder", defaultValue: "搜索已安装包"), text: $localSearchText)
.textFieldStyle(.roundedBorder)
.controlSize(.small)
Picker("", selection: $installedFilter) {
ForEach(BrewPackageFilter.allCases) { filter in
Text(filter.title(localization: localization)).tag(filter)
}
}
.pickerStyle(.menu)
.labelsHidden()
.controlSize(.small)
.frame(width: 96)
refreshButton(title: localization.string("detail.refresh.button", defaultValue: "刷新"))
}
.pluginSettingsListRowPadding(interactive: true)
}
private func installedPackageList(_ packages: [BrewPackage]) -> some View {
List(packages, selection: $selectedInstalledPkg) { pkg in
installedPackageRow(pkg)
.tag(pkg)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
private func installedPackageRow(_ pkg: BrewPackage) -> some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: pkg.isCask ? "macwindow" : "terminal")
.pluginSettingsRowIconStyle(pkg.isCask ? Color.blue : Color.green)
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(pkg.name)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
.lineLimit(1)
if !pkg.desc.isEmpty {
Text(pkg.desc)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
if pkg.isOutdated {
Text(localization.string("detail.search.hasUpdate", defaultValue: "可更新"))
.font(PluginSettingsTheme.Typography.statusBadge)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.15), in: Capsule())
.foregroundStyle(.orange)
}
Text(pkg.version)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.vertical, 3)
}
private var installedDetailPane: some View {
Group {
if let pkg = selectedInstalledPkg {
packageDetailView(for: pkg)
} else {
emptyState(
icon: "info.circle",
title: localization.string("detail.detail.selectionHint", defaultValue: "选择一个包查看详情")
)
.pluginSettingsCardBackground(.host)
}
}
}
private var installedEmptyState: some View {
let isUnfiltered = localSearchText.isEmpty && installedFilter == .all
return emptyState(
icon: controller.isBusy ? "arrow.clockwise" : "shippingbox",
title: controller.isBusy
? localization.string("detail.search.loadingInstalled", defaultValue: "正在加载 Homebrew 状态")
: (isUnfiltered
? localization.string("detail.search.notLoaded", defaultValue: "尚未加载已安装包")
: localization.string("detail.search.noMatch", defaultValue: "没有匹配的已安装包"))
)
}
private var searchTabContent: some View {
HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
VStack(spacing: 0) {
searchToolbar
PluginSettingsListDivider(leadingInset: 0, trailingInset: 0)
searchResultsContent
}
.pluginSettingsCardBackground(.host)
.frame(width: 340)
.frame(maxHeight: .infinity)
searchDetailPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent)
.padding(.horizontal, contentPadding)
.padding(.bottom, contentPadding)
}
private var searchToolbar: some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
TextField(localization.string("detail.search.onlinePlaceholder", defaultValue: "搜索软件包"), text: $onlineSearchText)
.textFieldStyle(.roundedBorder)
.controlSize(.small)
.onSubmit {
runOnlineSearch()
}
Button {
runOnlineSearch()
} label: {
Label(localization.string("detail.search.button", defaultValue: "搜索"), systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(onlineSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || controller.isSearching)
}
.pluginSettingsListRowPadding(interactive: true)
}
@ViewBuilder
private var searchResultsContent: some View {
if controller.isSearching {
emptyState(icon: "arrow.clockwise", title: localization.string("detail.search.searching", defaultValue: "正在搜索 Homebrew"))
} else if controller.searchResults.isEmpty {
emptyState(icon: "magnifyingglass", title: localization.string("detail.search.empty", defaultValue: "输入关键词搜索软件包"))
} else {
List(controller.searchResults, selection: $selectedSearchPkg) { pkg in
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: pkg.isCask ? "macwindow" : "terminal")
.pluginSettingsRowIconStyle(pkg.isCask ? Color.blue : Color.green)
Text(pkg.name)
.font(PluginSettingsTheme.Typography.rowTitle)
.lineLimit(1)
Spacer()
Text(pkg.isCask
? localization.string("detail.filter.cask", defaultValue: "Cask")
: localization.string("detail.filter.formula", defaultValue: "Formula"))
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(.secondary)
}
.tag(pkg)
.padding(.vertical, 3)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.onChange(of: selectedSearchPkg) { _, newPkg in
if let pkg = newPkg {
fetchOnlinePackageDetail(pkg)
}
}
}
}
@ViewBuilder
private var searchDetailPane: some View {
if isFetchingOnlineDetail {
emptyState(icon: "arrow.clockwise", title: localization.string("detail.search.fetchingDetail", defaultValue: "正在加载包详情"))
.pluginSettingsCardBackground(.host)
} else if let pkg = onlinePackageDetail {
onlinePackageDetailView(for: pkg)
} else {
emptyState(icon: "plus.circle", title: localization.string("detail.detail.onlineHint", defaultValue: "选择搜索结果后安装"))
.pluginSettingsCardBackground(.host)
}
}
private var tapsTabContent: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(title: localization.string("detail.taps.titleAdd", defaultValue: "添加软件源"), icon: "plus.circle")
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
TextField(localization.string("detail.taps.placeholderAdd", defaultValue: "user/repo 或仓库 URL"), text: $newTapName)
.textFieldStyle(.roundedBorder)
.controlSize(.small)
Button {
let trimmed = newTapName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
pendingAction = .tap(trimmed)
} label: {
Label(localization.string("detail.taps.buttonAdd", defaultValue: "添加"), systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(newTapName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || controller.isBusy)
}
.pluginSettingsListRowPadding(interactive: true)
.pluginSettingsCardBackground(.host)
}
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(
title: localization.format("detail.taps.titleList", defaultValue: "已启用软件源(%d", controller.taps.count),
icon: "square.stack.3d.up"
)
tapListCard
}
.frame(maxHeight: .infinity, alignment: .top)
}
.padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent)
.padding(.horizontal, contentPadding)
.padding(.bottom, contentPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var tapListCard: some View {
VStack(spacing: 0) {
if controller.taps.isEmpty {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: "square.stack.3d.up")
.pluginSettingsRowIconStyle()
Text(localization.string("detail.taps.empty", defaultValue: "暂无软件源"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Spacer()
}
.pluginSettingsListRowPadding()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(controller.taps) { tap in
tapRow(tap)
if tap.id != controller.taps.last?.id {
PluginSettingsListDivider()
}
}
}
}
.scrollIndicators(.automatic)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.pluginSettingsCardBackground(.host)
}
private func tapRow(_ tap: BrewTap) -> some View {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: "square.stack.3d.up")
.pluginSettingsRowIconStyle()
Text(tap.name)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
Button(role: .destructive) {
pendingAction = .untap(tap)
} label: {
Label(localization.string("detail.taps.buttonUntap", defaultValue: "移除"), systemImage: "minus.circle")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(controller.isBusy)
}
.pluginSettingsListRowPadding(interactive: true)
}
private var diagnosticsTabContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(title: localization.string("detail.diagnostics.pathTitle", defaultValue: "brew 路径"), icon: "terminal")
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
TextField(localization.string("detail.diagnostics.pathPlaceholder", defaultValue: "例如 /opt/homebrew/bin/brew"), text: $customBrewPath)
.textFieldStyle(.roundedBorder)
.controlSize(.small)
Button {
controller.updateCustomPath(customBrewPath)
} label: {
Label(localization.string("detail.diagnostics.pathApply", defaultValue: "应用"), systemImage: "checkmark")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(customBrewPath == controller.brewPath || customBrewPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.pluginSettingsListRowPadding(interactive: true)
.pluginSettingsCardBackground(.host)
}
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
sectionHeader(title: localization.string("detail.diagnostics.maintenance.title", defaultValue: "维护操作"), icon: "wrench.and.screwdriver")
VStack(spacing: 0) {
diagnosticRow(
title: localization.string("detail.diagnostics.update.title", defaultValue: "更新软件源"),
description: localization.string("detail.diagnostics.update.desc", defaultValue: "同步 Homebrew formula 和 cask 列表。"),
icon: "arrow.clockwise.circle.fill",
color: .blue,
action: { controller.updateBrew() }
)
PluginSettingsListDivider()
diagnosticRow(
title: localization.string("detail.diagnostics.upgrade.title", defaultValue: "更新所有包"),
description: localization.string("detail.diagnostics.upgrade.desc", defaultValue: "更新当前检测到的过期包。"),
icon: "arrow.up.circle.fill",
color: .orange,
action: { pendingAction = .upgradeAll }
)
PluginSettingsListDivider()
diagnosticRow(
title: localization.string("detail.diagnostics.doctor.title", defaultValue: "运行诊断"),
description: localization.string("detail.diagnostics.doctor.desc", defaultValue: "检查环境变量、权限和构建路径。"),
icon: "heart.text.square.fill",
color: .green,
action: { controller.runDoctor() }
)
PluginSettingsListDivider()
diagnosticRow(
title: localization.string("detail.diagnostics.cleanup.title", defaultValue: "清理缓存"),
description: localization.string("detail.diagnostics.cleanup.desc", defaultValue: "移除旧版本下载和缓存。"),
icon: "trash.circle.fill",
color: .red,
action: { pendingAction = .cleanup }
)
}
.pluginSettingsCardBackground(.host)
}
}
.padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent)
.padding(.bottom, contentPadding)
}
.padding(.horizontal, contentPadding)
}
private var pathNotFoundView: some View {
VStack(spacing: PluginSettingsTheme.Spacing.section) {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: PluginSettingsTheme.Size.pageIcon))
.foregroundStyle(.orange)
Text(localization.string("detail.diagnostics.pathNotFoundTitle", defaultValue: "未检测到 Homebrew"))
.font(PluginSettingsTheme.Typography.pageTitle)
Text(localization.string("detail.diagnostics.pathNotFoundDesc", defaultValue: "请确认已安装 Homebrew,或手动指定 brew 可执行文件路径。"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) {
TextField(localization.string("detail.diagnostics.pathPlaceholder", defaultValue: "例如 /opt/homebrew/bin/brew"), text: $customBrewPath)
.textFieldStyle(.roundedBorder)
.controlSize(.small)
Button {
controller.updateCustomPath(customBrewPath)
} label: {
Label(localization.string("detail.diagnostics.pathApply", defaultValue: "应用"), systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(customBrewPath.isEmpty)
}
}
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Text(localization.string("detail.diagnostics.pathHelpTitle", defaultValue: "常见安装位置"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("Apple Silicon Mac: /opt/homebrew/bin/brew")
.font(.caption)
.foregroundStyle(.secondary)
Text("Intel Mac: /usr/local/bin/brew")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 12)
Spacer()
}
.padding(PluginSettingsTheme.Spacing.pagePadding)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.pluginSettingsCardBackground(.host)
}
private func diagnosticRow(
title: String,
description: String,
icon: String,
color: Color,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) {
Image(systemName: icon)
.pluginSettingsRowIconStyle(color, visualScale: 1.1)
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
Text(title)
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Text(description)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl)
Button {
action()
} label: {
Label(localization.string("detail.diagnostics.buttonExecute", defaultValue: "执行"), systemImage: "play")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(controller.isBusy)
}
.pluginSettingsListRowPadding(interactive: true)
}
// MARK: - Component Views
private func packageDetailView(for pkg: BrewPackage) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: pkg.isCask ? "macwindow" : "terminal")
.font(.system(size: 20))
.foregroundStyle(pkg.isCask ? .blue : .green)
Text(pkg.name)
.font(PluginSettingsTheme.Typography.pageTitle)
Spacer()
if pkg.isPinned {
Label(localization.string("detail.detail.pinned", defaultValue: "Pinned"), systemImage: "pin.fill")
.font(PluginSettingsTheme.Typography.statusBadge)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.yellow.opacity(0.15))
.foregroundStyle(.yellow)
.cornerRadius(4)
}
}
if !pkg.desc.isEmpty {
Text(pkg.desc)
.font(PluginSettingsTheme.Typography.rowTitle)
.foregroundStyle(.secondary)
}
VStack(spacing: 8) {
infoRow(label: localization.string("detail.detail.type", defaultValue: "Type"), value: pkg.isCask ? localization.string("detail.detail.typeCask", defaultValue: "GUI App (Cask)") : localization.string("detail.detail.typeFormula", defaultValue: "CLI Package (Formula)"))
infoRow(label: localization.string("detail.detail.installedVer", defaultValue: "Installed"), value: pkg.version)
infoRow(label: localization.string("detail.detail.latestVer", defaultValue: "Latest"), value: pkg.latestVersion)
if !pkg.homepage.isEmpty {
HStack {
Text(localization.string("detail.detail.website", defaultValue: "Homepage")).font(PluginSettingsTheme.Typography.rowDescription).foregroundStyle(.secondary)
Spacer()
if let homepageURL = validatedHomepageURL(pkg.homepage) {
Link(pkg.homepage, destination: homepageURL)
.font(PluginSettingsTheme.Typography.rowDescription)
.lineLimit(1)
} else {
Text(pkg.homepage)
.font(PluginSettingsTheme.Typography.rowDescription)
.lineLimit(1)
.textSelection(.enabled)
}
}
}
}
.padding(10)
.pluginSettingsCardBackground(.recessed)
if !pkg.dependencies.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(localization.format("detail.detail.dependencies", defaultValue: "Dependencies (%d)", pkg.dependencies.count))
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(pkg.dependencies, id: \.self) { dep in
Button {
navigateToPackage(name: dep)
} label: {
Text(dep)
.font(PluginSettingsTheme.Typography.monospacedValue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.primary.opacity(0.05))
.cornerRadius(4)
}
.buttonStyle(.plain)
}
}
}
}
}
let usedBy = pkg.requiredBy(in: controller.installedPackages)
if !usedBy.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(localization.format("detail.detail.usedBy", defaultValue: "Required By (%d)", usedBy.count))
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(usedBy, id: \.self) { parent in
Button {
navigateToPackage(name: parent)
} label: {
Text(parent)
.font(PluginSettingsTheme.Typography.monospacedValue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.primary.opacity(0.05))
.cornerRadius(4)
}
.buttonStyle(.plain)
}
}
}
}
}
Spacer()
// Actions
HStack(spacing: 10) {
if pkg.isOutdated {
Button {
controller.upgrade(package: pkg)
} label: {
Label(localization.string("detail.detail.actionUpgrade", defaultValue: "Upgrade"), systemImage: "arrow.up.circle")
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
.disabled(controller.isBusy)
}
if !pkg.isPinned {
Button {
if pkg.isPinned {
controller.unpin(package: pkg)
} else {
controller.pin(package: pkg)
}
} label: {
Label(pkg.isPinned ? localization.string("detail.detail.actionUnpin", defaultValue: "Unpin") : localization.string("detail.detail.actionPin", defaultValue: "Pin Version"), systemImage: pkg.isPinned ? "pin.slash" : "pin")
}
.buttonStyle(.bordered)
.controlSize(.regular)
.disabled(controller.isBusy)
} else {
Button {
if pkg.isPinned {
controller.unpin(package: pkg)
} else {
controller.pin(package: pkg)
}
} label: {
Label(pkg.isPinned ? localization.string("detail.detail.actionUnpin", defaultValue: "Unpin") : localization.string("detail.detail.actionPin", defaultValue: "Pin Version"), systemImage: pkg.isPinned ? "pin.slash" : "pin")
}
.buttonStyle(.bordered)
.controlSize(.regular)
.disabled(controller.isBusy)
}
Button(role: .destructive) {
pendingAction = .uninstall(pkg)
} label: {
Label(localization.string("detail.detail.actionUninstall", defaultValue: "Uninstall"), systemImage: "trash")
}
.buttonStyle(.bordered)
.controlSize(.regular)
.disabled(controller.isBusy)
}
}
.padding(14)
.pluginSettingsCardBackground(.host)
}
private func onlinePackageDetailView(for pkg: BrewPackage) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: pkg.isCask ? "macwindow" : "terminal")
.font(.system(size: 20))
.foregroundStyle(pkg.isCask ? .blue : .green)
Text(pkg.name)
.font(PluginSettingsTheme.Typography.pageTitle)
Spacer()
}
if !pkg.desc.isEmpty {
Text(pkg.desc)
.font(PluginSettingsTheme.Typography.rowTitle)
.foregroundStyle(.secondary)
}
VStack(spacing: 8) {
infoRow(
label: localization.string("detail.detail.type", defaultValue: "Type"),
value: pkg.isCask
? localization.string("detail.filter.cask", defaultValue: "Cask")
: localization.string("detail.filter.formula", defaultValue: "Formula")
)
infoRow(label: localization.string("detail.detail.latestAvailableVer", defaultValue: "Latest Available"), value: pkg.latestVersion)
if !pkg.homepage.isEmpty {
HStack {
Text(localization.string("detail.detail.website", defaultValue: "Homepage")).font(PluginSettingsTheme.Typography.rowDescription).foregroundStyle(.secondary)
Spacer()
if let homepageURL = validatedHomepageURL(pkg.homepage) {
Link(pkg.homepage, destination: homepageURL)
.font(PluginSettingsTheme.Typography.rowDescription)
.lineLimit(1)
} else {
Text(pkg.homepage)
.font(PluginSettingsTheme.Typography.rowDescription)
.lineLimit(1)
.textSelection(.enabled)
}
}
}
}
.padding(10)
.pluginSettingsCardBackground(.recessed)
Spacer()
// Actions
let alreadyInstalled = controller.installedPackages.contains { $0.name == pkg.name }
Button {
controller.install(package: pkg)
} label: {
Label(alreadyInstalled ? localization.string("detail.search.installAgainAction", defaultValue: "Install Again") : localization.string("detail.search.installAction", defaultValue: "Install"), systemImage: "plus.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(controller.isBusy)
if alreadyInstalled {
Text(localization.string("detail.search.installedHint", defaultValue: "This package is already installed on your system."))
.font(.caption)
.foregroundStyle(.green)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.padding(14)
.pluginSettingsCardBackground(.host)
}
private func infoRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.font(PluginSettingsTheme.Typography.rowDescription)
}
}
// MARK: - Console Output Drawer
private var consoleDrawer: some View {
VStack(spacing: 0) {
Divider()
// Terminal Header
HStack {
HStack(spacing: 6) {
Circle()
.fill(controller.isBusy ? Color.green : Color.gray)
.frame(width: 8, height: 8)
.opacity(controller.isBusy ? 0.8 : 0.4)
Text(localization.string("detail.console.title", defaultValue: "Terminal Console Logs"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Spacer()
Button(localization.string("detail.console.buttonClear", defaultValue: "Clear")) {
controller.clearLogs()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.primary.opacity(0.05))
.cornerRadius(4)
if controller.isBusy {
Button(localization.string("detail.console.buttonCancel", defaultValue: "Cancel Operation")) {
controller.cancelCurrentOperation()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.1))
.cornerRadius(4)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.02))
// Scrollable Console
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(controller.logs.suffix(150)) { log in
Text(log.text)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(log.isError ? Color.red : Color.green)
.frame(maxWidth: .infinity, alignment: .leading)
.id(log.id)
}
}
.padding(8)
}
.background(Color(nsColor: .textBackgroundColor).opacity(0.95))
.onChange(of: controller.logs.count) { _, _ in
if let last = controller.logs.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
// MARK: - Helper Methods
private func requestInitialScanIfNeeded() {
guard !didRequestInitialScan else { return }
guard controller.isBrewAvailable, !controller.isBusy else { return }
guard controller.installedPackages.isEmpty,
controller.outdatedPackages.isEmpty,
controller.taps.isEmpty else {
return
}
didRequestInitialScan = true
controller.scanAll()
}
private func emptyState(icon: String, title: String) -> some View {
VStack(spacing: PluginSettingsTheme.Spacing.controlCluster) {
if icon == "arrow.clockwise" {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: icon)
.font(.system(size: PluginSettingsTheme.Size.emptyStateIcon))
.foregroundStyle(.secondary)
}
Text(title)
.font(PluginSettingsTheme.Typography.pageDescription)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func fetchOnlinePackageDetail(_ pkg: BrewPackage) {
let requestedName = pkg.name
let requestedIsCask = pkg.isCask
isFetchingOnlineDetail = true
onlinePackageDetail = nil
Task {
defer {
if selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask {
isFetchingOnlineDetail = false
}
}
do {
let tempRunner = controller.runner
var jsonOutput = ""
_ = try await tempRunner.run(
executable: controller.brewPath,
arguments: ["info", "--json=v2", pkg.name],
onOutput: { jsonOutput += $0 },
onError: { _ in }
)
guard selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask else { return }
guard let data = jsonOutput.data(using: .utf8) else {
return
}
struct BrewInfoResponse: Codable {
let formulae: [FormulaInfo]
let casks: [CaskInfo]
}
struct FormulaInfo: Codable {
let name: String
let desc: String?
let homepage: String?
let versions: StableVersion?
}
struct StableVersion: Codable {
let stable: String?
}
struct CaskInfo: Codable {
let token: String
let name: [String]?
let desc: String?
let homepage: String?
let version: String?
}
let response = try JSONDecoder().decode(BrewInfoResponse.self, from: data)
guard selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask else { return }
if pkg.isCask, let cask = response.casks.first {
onlinePackageDetail = BrewPackage(
name: cask.token,
version: "",
latestVersion: cask.version ?? "",
isCask: true,
desc: cask.desc ?? (cask.name?.first ?? ""),
homepage: cask.homepage ?? "",
isOutdated: false,
isPinned: false
)
} else if let formula = response.formulae.first {
onlinePackageDetail = BrewPackage(
name: formula.name,
version: "",
latestVersion: formula.versions?.stable ?? "",
isCask: false,
desc: formula.desc ?? "",
homepage: formula.homepage ?? "",
isOutdated: false,
isPinned: false
)
}
} catch {}
}
}
private func navigateToPackage(name: String) {
if let found = controller.installedPackages.first(where: { $0.name == name }) {
activeTab = .installed
selectedInstalledPkg = found
} else {
activeTab = .search
onlineSearchText = name
runOnlineSearch()
}
}
private func runOnlineSearch() {
let trimmed = onlineSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
selectedSearchPkg = nil
onlinePackageDetail = nil
controller.search(query: trimmed)
}
private func perform(_ action: HomebrewPendingAction) {
switch action {
case .upgradeAll:
controller.upgradeAll()
case .cleanup:
controller.runCleanup()
case let .uninstall(package):
controller.uninstall(package: package)
case let .tap(name):
controller.tapRepository(name: name)
newTapName = ""
case let .untap(tap):
controller.untapRepository(tap: tap)
}
}
private func validatedHomepageURL(_ value: String) -> URL? {
guard let components = URLComponents(string: value),
let scheme = components.scheme?.lowercased(),
scheme == "https" || scheme == "http",
let url = components.url else {
return nil
}
return url
}
}
private enum HomebrewPendingAction: Identifiable {
case upgradeAll
case cleanup
case uninstall(BrewPackage)
case tap(String)
case untap(BrewTap)
var id: String {
switch self {
case .upgradeAll:
return "upgrade-all"
case .cleanup:
return "cleanup"
case let .uninstall(package):
return "uninstall-\(package.name)"
case let .tap(name):
return "tap-\(name)"
case let .untap(tap):
return "untap-\(tap.name)"
}
}
var role: ButtonRole? {
switch self {
case .cleanup,
.uninstall,
.untap:
return .destructive
case .upgradeAll,
.tap:
return nil
}
}
func title(localization: PluginLocalization) -> String {
switch self {
case .upgradeAll:
return localization.string("detail.confirm.upgradeAll.title", defaultValue: "确认更新所有包?")
case .cleanup:
return localization.string("detail.confirm.cleanup.title", defaultValue: "确认清理 Homebrew 缓存?")
case let .uninstall(package):
return localization.format(
"detail.confirm.uninstall.title",
defaultValue: "确认卸载 %@",
package.name
)
case .tap:
return localization.string("detail.confirm.tap.title", defaultValue: "确认添加软件源?")
case let .untap(tap):
return localization.format(
"detail.confirm.untap.title",
defaultValue: "确认移除 %@",
tap.name
)
}
}
func message(localization: PluginLocalization) -> String {
switch self {
case .upgradeAll:
return localization.string(
"detail.confirm.upgradeAll.message",
defaultValue: "将执行 brew upgrade,更新所有可升级的 Homebrew 包。此操作可能修改已安装软件。"
)
case .cleanup:
return localization.string(
"detail.confirm.cleanup.message",
defaultValue: "将执行 brew cleanup,删除 Homebrew 旧版本和下载缓存。"
)
case let .uninstall(package):
return localization.format(
"detail.confirm.uninstall.message",
defaultValue: "将执行 brew uninstall %@。如果其他包依赖它,相关功能可能受到影响。",
package.name
)
case let .tap(name):
return localization.format(
"detail.confirm.tap.message",
defaultValue: "将执行 brew tap %@Homebrew 会从该软件源获取配方和 cask 信息。",
name
)
case let .untap(tap):
return localization.format(
"detail.confirm.untap.message",
defaultValue: "将执行 brew untap %@。依赖该软件源的包可能无法继续更新。",
tap.name
)
}
}
func buttonTitle(localization: PluginLocalization) -> String {
switch self {
case .upgradeAll:
return localization.string("detail.confirm.upgradeAll.button", defaultValue: "更新全部")
case .cleanup:
return localization.string("detail.confirm.cleanup.button", defaultValue: "清理")
case .uninstall:
return localization.string("detail.confirm.uninstall.button", defaultValue: "卸载")
case .tap:
return localization.string("detail.confirm.tap.button", defaultValue: "添加")
case .untap:
return localization.string("detail.confirm.untap.button", defaultValue: "移除")
}
}
}