1342 lines
56 KiB
Swift
1342 lines
56 KiB
Swift
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: "移除")
|
||
}
|
||
}
|
||
}
|