mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
Part 1 — Simplified Chinese localization of the iOS app layer: - ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog (395 keys, zh-Hans; en implicit as source). Terminology mined from web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/ 机器/语音输入/用量/草稿夹 …). - Mechanical edits only: merged multi-part string concatenations into single LocalizedStringKey literals, converted ternary/plain-String user copy to String(localized:), switched helper params (detailRow, DashboardCard, optionPicker, notice) to LocalizedStringKey. - LocalizedNoticeMapper (app layer): display-point translation of the 27 known HapiKit-emitted strings (ChatInteractor notices, dictation errors, window-sync warnings, files fallbacks, worktree-name validation) with verbatim passthrough for server-originated text; package stays language-free. - Deliberately untranslated (web parity / non-copy): event rows (EventPresentation — web renders presentation.ts verbatim), catalog option labels (Default/Auto/Sonnet/permission modes — web shows them verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill, Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators. - Language row wired: AppLanguage gains .system (follow system, new default); explicit picks write the AppleLanguages override, Follow system removes it; footer notes a relaunch applies it (no supported in-place SwiftUI locale swap). - project.pbxproj: zh-Hans added to knownRegions. Part 2 — #39 catalog dedupe (HapiProtocol): - NewSessionCatalogs.claudeModels/claudeEfforts now derive from ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog); codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS minus max); effortLabel delegates to ModelCatalog.capitalizedFirst. - New CatalogTests lock test for the derived option lists. Gate: bash ios/scripts/linux-test.sh — 459 tests green.
211 lines
7.3 KiB
Swift
211 lines
7.3 KiB
Swift
import Charts
|
|
import HapiClient
|
|
import HapiProtocol
|
|
import SwiftUI
|
|
|
|
/// Storage-dashboard state: initial load + explicit refresh (web parity).
|
|
/// Android reference: `feature/settings/StorageViewModel.kt`.
|
|
@MainActor @Observable
|
|
final class StorageModel {
|
|
enum State {
|
|
case loading
|
|
/// `isForbidden` (403): non-owner namespace (mirror of the usage
|
|
/// screen).
|
|
case error(message: String?, isForbidden: Bool)
|
|
case data(usage: SqliteStorageUsageResponse, isRefreshing: Bool)
|
|
}
|
|
|
|
private(set) var state: State = .loading
|
|
|
|
private let api: APIClient
|
|
private var loadTask: Task<Void, Never>?
|
|
|
|
init(api: APIClient) {
|
|
self.api = api
|
|
}
|
|
|
|
func start() {
|
|
guard loadTask == nil else { return }
|
|
refresh()
|
|
}
|
|
|
|
func refresh() {
|
|
if case .data(_, true) = state { return }
|
|
loadTask?.cancel()
|
|
if case .data(let usage, _) = state {
|
|
state = .data(usage: usage, isRefreshing: true)
|
|
} else {
|
|
state = .loading
|
|
}
|
|
loadTask = Task {
|
|
do {
|
|
let usage = try await self.api.sqliteStorageUsage()
|
|
guard !Task.isCancelled else { return }
|
|
self.state = .data(usage: usage, isRefreshing: false)
|
|
} catch {
|
|
guard !Task.isCancelled else { return }
|
|
self.state = .error(
|
|
message: error.localizedDescription,
|
|
isForbidden: (error as? APIError)?.status == 403
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Owner-only sqlite storage dashboard (web
|
|
/// `web/src/routes/settings/storage.tsx` + `StorageUsagePie` twin via the
|
|
/// Android port): a Swift Charts `SectorMark` donut of db/wal/shm with the
|
|
/// total in the center, legend rows with byte formatting + percents, the
|
|
/// total and path rows, and an explicit refresh.
|
|
struct StorageView: View {
|
|
@State private var model: StorageModel
|
|
|
|
init(api: APIClient) {
|
|
_model = State(initialValue: StorageModel(api: api))
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Disk footprint of the hub's SQLite database (database, write-ahead log, shared memory).")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
switch model.state {
|
|
case .loading:
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 48)
|
|
case .error(let message, let isForbidden):
|
|
DashboardErrorView(isForbidden: isForbidden, message: message) {
|
|
model.refresh()
|
|
}
|
|
case .data(let usage, let isRefreshing):
|
|
StorageUsageCard(usage: usage)
|
|
HStack {
|
|
Spacer()
|
|
Button(isRefreshing
|
|
? String(localized: "Refreshing…")
|
|
: String(localized: "Refresh")) {
|
|
model.refresh()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isRefreshing)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(Color(.systemGroupedBackground))
|
|
.navigationTitle("Storage")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task {
|
|
model.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Donut + legend + total/path rows for one sqlite usage snapshot.
|
|
private struct StorageUsageCard: View {
|
|
let usage: SqliteStorageUsageResponse
|
|
|
|
var body: some View {
|
|
let slices = StorageMath.slices(usage)
|
|
DashboardCard(title: "SQLite files") {
|
|
if slices.isEmpty {
|
|
Text("No storage data reported.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
donut(slices: slices)
|
|
VStack(spacing: 8) {
|
|
ForEach(slices, id: \.key) { slice in
|
|
legendRow(slice)
|
|
}
|
|
}
|
|
}
|
|
Divider()
|
|
LabeledContent {
|
|
Text(UsageMath.formatBytes(usage.totalBytes))
|
|
} label: {
|
|
Text("Total")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.subheadline)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Path")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(usage.path)
|
|
.font(.caption.monospaced())
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `SectorMark` derives its shares from the byte values; data order is
|
|
/// the fixed entity order from `StorageMath`, and the default 12-o'clock
|
|
/// start matches the web geometry. The hero total sits in the hole.
|
|
private func donut(slices: [StorageMath.Slice]) -> some View {
|
|
Chart(slices, id: \.key) { slice in
|
|
SectorMark(
|
|
angle: .value("Bytes", slice.bytes),
|
|
innerRadius: .ratio(0.62),
|
|
angularInset: slices.count > 1 ? 1.5 : 0
|
|
)
|
|
.foregroundStyle(sliceColor(slice.key))
|
|
.cornerRadius(2)
|
|
}
|
|
.chartBackground { proxy in
|
|
GeometryReader { geo in
|
|
if let plotFrame = proxy.plotFrame {
|
|
let frame = geo[plotFrame]
|
|
VStack(spacing: 2) {
|
|
Text(UsageMath.formatBytes(usage.totalBytes))
|
|
.font(.title3.weight(.semibold))
|
|
Text("Total")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.position(x: frame.midX, y: frame.midY)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 200)
|
|
}
|
|
|
|
private func legendRow(_ slice: StorageMath.Slice) -> some View {
|
|
HStack(spacing: 10) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(sliceColor(slice.key))
|
|
.frame(width: 10, height: 10)
|
|
Text(sliceLabel(slice.key))
|
|
.font(.subheadline)
|
|
Spacer(minLength: 12)
|
|
Text("\(UsageMath.formatBytes(slice.bytes)) · \(StorageMath.formatPercent(slice.percent))")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
/// Fixed per entity — a missing wal/shm file never repaints its
|
|
/// neighbors — and the legend rows carry identity, so color is never the
|
|
/// only channel. Accent-anchored so all theme modes track.
|
|
private func sliceColor(_ key: StorageMath.SliceKey) -> Color {
|
|
switch key {
|
|
case .database: return Color.accentColor
|
|
case .wal: return Color.accentColor.opacity(0.55)
|
|
case .shm: return Color.secondary
|
|
}
|
|
}
|
|
|
|
private func sliceLabel(_ key: StorageMath.SliceKey) -> String {
|
|
switch key {
|
|
case .database: return String(localized: "Database")
|
|
case .wal: return String(localized: "Write-ahead log")
|
|
case .shm: return String(localized: "Shared memory")
|
|
}
|
|
}
|
|
}
|