Files
steipete--codexbar/Sources/CodexBar/StorageBreakdownMenuView.swift
T
2026-07-13 12:22:33 +08:00

368 lines
13 KiB
Swift

import AppKit
import CodexBarCore
import SwiftUI
struct StorageBreakdownMenuView: View {
let footprint: ProviderStorageFootprint
let width: CGFloat
let maxHeight: CGFloat
let onExpansionHeightChange: ((CGFloat) -> Void)?
@State private var otherExpanded = false
init(
footprint: ProviderStorageFootprint,
width: CGFloat,
maxHeight: CGFloat = 560,
onExpansionHeightChange: ((CGFloat) -> Void)? = nil)
{
self.footprint = footprint
self.width = width
self.maxHeight = maxHeight
self.onExpansionHeightChange = onExpansionHeightChange
}
/// One entry in the segmented bar and its matching legend row. Overflow components past the row
/// budget collapse into a single trailing "Other" segment with no copyable path of its own.
private struct Segment: Identifiable {
let id: String
let name: String
let bytes: Int64
let color: Color
let path: String?
}
/// How many legend rows we let the breakdown show before collapsing the tail into "Other".
private static let maxRows = 8
private static let segmentPalette: [Color] = [
Color(red: 0.20, green: 0.51, blue: 0.96),
Color(red: 0.96, green: 0.55, blue: 0.20),
Color(red: 0.30, green: 0.78, blue: 0.47),
Color(red: 0.66, green: 0.42, blue: 0.93),
Color(red: 0.95, green: 0.74, blue: 0.22),
Color(red: 0.92, green: 0.36, blue: 0.55),
Color(red: 0.27, green: 0.76, blue: 0.82),
]
private static let otherColor = Color(nsColor: .tertiaryLabelColor)
private static let overflowRowHeight: CGFloat = 18
private static let overflowRowSpacing: CGFloat = 4
private static let overflowTopSpacing: CGFloat = 6
var cleanupRecommendations: [ProviderStorageRecommendation] {
self.footprint.cleanupRecommendations
}
var copyablePaths: [String] {
let recommendationPaths = self.cleanupRecommendations.map(\.path)
return self.footprint.components.map(\.path) + recommendationPaths
}
/// Visible components mapped to colored segments, with any tail beyond `maxRows` folded into a
/// single "Other" entry so the bar and legend never exceed the row budget.
private var segments: [Segment] {
let components = self.footprint.components
guard !components.isEmpty else { return [] }
func color(_ index: Int) -> Color {
Self.segmentPalette[index % Self.segmentPalette.count]
}
func segment(_ component: ProviderStorageFootprint.Component, _ index: Int) -> Segment {
Segment(
id: component.id,
name: component.name,
bytes: max(component.totalBytes, 0),
color: color(index),
path: component.path)
}
if components.count <= Self.maxRows {
return components.enumerated().map { segment($1, $0) }
}
let visible = components.prefix(Self.maxRows - 1)
let overflow = components.dropFirst(Self.maxRows - 1)
let otherBytes = overflow.reduce(Int64(0)) { partial, component in
let bytes = max(component.totalBytes, 0)
let (sum, overflowed) = partial.addingReportingOverflow(bytes)
return overflowed ? .max : sum
}
return visible.enumerated().map { segment($1, $0) } + [
Segment(
id: "__other__",
name: String(format: L("Other (%d items)"), overflow.count),
bytes: otherBytes,
color: Self.otherColor,
path: nil),
]
}
private var segmentTotalBytes: Double {
self.segments.reduce(0) { $0 + Double($1.bytes) }
}
/// The components folded into the trailing "Other" segment, revealed when it is expanded.
private var overflowComponents: [ProviderStorageFootprint.Component] {
let components = self.footprint.components
guard components.count > Self.maxRows else { return [] }
return Array(components.dropFirst(Self.maxRows - 1))
}
private var overflowExpansionHeight: CGFloat {
let count = CGFloat(self.overflowComponents.count)
guard count > 0 else { return 0 }
return Self.overflowTopSpacing
+ count * Self.overflowRowHeight
+ (count - 1) * Self.overflowRowSpacing
}
var body: some View {
ScrollView(.vertical) {
self.content
}
.scrollIndicators(.visible)
.frame(
minWidth: self.width,
idealWidth: self.width,
maxWidth: self.width,
maxHeight: self.maxHeight,
alignment: .topLeading)
}
private var content: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Text(L("Storage"))
.font(.body)
.fontWeight(.medium)
Text(String(format: L("Total: %@"), UsageFormatter.byteCountStringLong(self.footprint.totalBytes)))
.font(.caption)
.foregroundStyle(.secondary)
}
if self.segments.isEmpty {
Text(L("No local data found"))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
self.segmentedBar
VStack(alignment: .leading, spacing: 6) {
ForEach(self.segments) { segment in
self.legendRow(segment)
}
}
}
if !self.cleanupRecommendations.isEmpty {
Divider()
.padding(.vertical, 2)
VStack(alignment: .leading, spacing: 8) {
Text(L("Cleanup ideas"))
.font(.body)
.fontWeight(.medium)
ForEach(self.cleanupRecommendations) { recommendation in
self.recommendationRow(recommendation)
}
}
}
if !self.footprint.unreadablePaths.isEmpty {
Text(String(format: L("%d unreadable item(s) skipped"), self.footprint.unreadablePaths.count))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(width: self.width, alignment: .leading)
}
private var segmentedBar: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule()
.fill(Color(nsColor: .quaternaryLabelColor))
HStack(spacing: 0) {
ForEach(self.segments) { segment in
Rectangle()
.fill(segment.color)
.frame(width: self.segmentWidth(segment, barWidth: proxy.size.width))
}
}
}
.clipShape(Capsule())
}
.frame(height: 5)
}
/// Each segment gets at least `minWidth` so tiny components stay visible, with the remaining width
/// shared by byte proportion. Reserving the minimums (rather than flooring each width with `max`)
/// keeps the segments summing to exactly `barWidth`, so none get clipped off the capsule's end.
private func segmentWidth(_ segment: Segment, barWidth: CGFloat) -> CGFloat {
let minWidth: CGFloat = 2
let count = CGFloat(self.segments.count)
guard self.segmentTotalBytes > 0 else { return barWidth / max(count, 1) }
let reserved = minWidth * count
guard barWidth > reserved else { return barWidth / max(count, 1) }
let remainder = barWidth - reserved
let proportion = CGFloat(Double(segment.bytes) / self.segmentTotalBytes)
return minWidth + remainder * proportion
}
private func legendRow(_ segment: Segment) -> some View {
let isOther = segment.path == nil
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(segment.color)
.frame(width: 9, height: 9)
Text(segment.name)
.font(.caption)
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.middle)
.help(segment.path ?? segment.name)
.layoutPriority(1)
Spacer()
if let path = segment.path {
StoragePathCopyButton(path: path)
} else {
self.otherExpandButton
}
Text(UsageFormatter.byteCountString(segment.bytes))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if isOther, self.otherExpanded {
self.overflowList
}
}
}
private var otherExpandButton: some View {
Button {
self.otherExpanded.toggle()
self.onExpansionHeightChange?(self.otherExpanded ? self.overflowExpansionHeight : 0)
} label: {
Image(systemName: self.otherExpanded ? "chevron.down" : "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(self.otherExpanded ? L("Collapse") : L("Expand"))
.accessibilityLabel(self.otherExpanded ? L("Collapse") : L("Expand"))
}
/// Plain name + size rows for the items folded into "Other" — no colors, indented under its name.
private var overflowList: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(self.overflowComponents) { component in
HStack(spacing: 8) {
Text(component.name)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.help(component.path)
Spacer()
StoragePathCopyButton(path: component.path)
Text(UsageFormatter.byteCountString(component.totalBytes))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.padding(.leading, 17)
}
private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View {
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(L(recommendation.title))
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
Spacer()
Text(UsageFormatter.byteCountString(recommendation.bytes))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
HStack(spacing: 4) {
Text(recommendation.path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.help(recommendation.path)
.layoutPriority(1)
Spacer()
StoragePathCopyButton(path: recommendation.path)
}
Text(L(recommendation.consequence))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
}
}
#if DEBUG
extension StorageBreakdownMenuView {
var _segmentNamesForTesting: [String] {
self.segments.map(\.name)
}
var _segmentBytesForTesting: [Int64] {
self.segments.map(\.bytes)
}
var _overflowNamesForTesting: [String] {
self.overflowComponents.map(\.name)
}
var _overflowExpansionHeightForTesting: CGFloat {
self.overflowExpansionHeight
}
func _segmentWidthsForTesting(barWidth: CGFloat) -> [CGFloat] {
self.segments.map { self.segmentWidth($0, barWidth: barWidth) }
}
}
#endif
struct StoragePathCopyButton: View {
let path: String
@State private var didCopy = false
@State private var resetTask: Task<Void, Never>?
var body: some View {
Button {
self.resetTask?.cancel()
MenuPasteboardCopy.perform(self.path, completion: {
self.didCopy = true
self.resetTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(0.9))
self.didCopy = false
}
})
} label: {
Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 18, height: 18)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(self.didCopy ? L("Copied") : L("Copy path"))
.accessibilityLabel(self.didCopy ? L("Copied") : L("Copy path"))
}
}