Files
hapi/ios/Hapi/Features/Files/FilesView.swift
T
weishu f0fe2f2775 fix(ios): clean up localization catalog extraction
Translate the unknown delivery state, keep decorative content verbatim, and pin integer interpolation formats to existing catalog keys.
2026-09-12 12:07:54 +08:00

427 lines
15 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// Session files browser (A-M4a), pushed from the chat toolbar — the iOS
/// take on web `files.tsx` / Android `FilesScreen`: **Changes** (branch
/// header incl. detached, staged/unstaged sections with status letters and
/// ±counts, degraded-numstat banner), **Browse** (lazily expanded directory
/// outline with per-node cache, dirs-first sort, hidden-file toggle),
/// **Search** (debounced ripgrep query, limit 200). Rows push the file
/// viewer — Changes rows carry their staged side so the viewer opens on the
/// right diff.
struct FilesView: View {
private let session: HubSession
private let sessionId: String
@State private var model: FilesModel
@State private var tab: FilesTab = .changes
@State private var viewerRoute: FileViewerRoute?
init(session: HubSession, sessionId: String) {
self.session = session
self.sessionId = sessionId
_model = State(initialValue: FilesModel(sessionId: sessionId, requester: session.api))
}
private enum FilesTab: String, CaseIterable, Identifiable {
case changes = "Changes"
case browse = "Browse"
case search = "Search"
var id: String { rawValue }
var label: String {
switch self {
case .changes: String(localized: "Changes")
case .browse: String(localized: "Browse")
case .search: String(localized: "Search")
}
}
}
var body: some View {
VStack(spacing: 0) {
Picker("Section", selection: $tab) {
ForEach(FilesTab.allCases) { tab in
Text(tab.label).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.vertical, 8)
switch tab {
case .changes:
ChangesTabView(state: model.changes) { path, staged in
viewerRoute = FileViewerRoute(sessionId: sessionId, path: path, staged: staged)
}
case .browse:
BrowseTabView(
state: model.browse,
onToggleDirectory: { model.toggleDirectory(path: $0) },
onToggleHidden: { model.setShowHidden($0) },
onOpenFile: { path in
viewerRoute = FileViewerRoute(sessionId: sessionId, path: path)
}
)
case .search:
SearchTabView(
state: model.search,
onQueryChange: { model.setSearchQuery($0) },
onOpenFile: { path in
viewerRoute = FileViewerRoute(sessionId: sessionId, path: path)
}
)
}
}
.navigationTitle("Files")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
switch tab {
case .changes: model.refreshChanges()
case .browse: model.refreshBrowse()
case .search: model.refreshSearch()
}
} label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel("Refresh")
}
}
.navigationDestination(item: $viewerRoute) { route in
// A replaced route value must rebuild the screen's @State model
// (HomeView's chat-push precedent).
FileViewerView(session: session, route: route)
.id(route)
}
.onAppear {
model.start()
}
}
}
// MARK: - Changes tab
private struct ChangesTabView: View {
let state: FilesModel.ChangesState
let onOpenFile: (_ path: String, _ staged: Bool) -> Void
var body: some View {
VStack(spacing: 0) {
if let error = state.error {
ErrorBanner(message: LocalizedNoticeMapper.map(error))
}
if state.isLoading && state.status == nil {
CenteredProgress()
} else if let status = state.status {
header(status)
if status.stagedFiles.isEmpty && status.unstagedFiles.isEmpty {
CenteredHint(text: String(localized: "No changes in the working tree"))
Spacer(minLength: 0)
} else {
changesList(status)
}
} else {
CenteredHint(text: String(localized: "Git status unavailable for this session"))
Spacer(minLength: 0)
}
}
}
private func header(_ status: GitStatusFiles) -> some View {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text(status.branch ?? String(localized: "Detached HEAD"))
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text("\(status.totalStaged, specifier: "%lld") staged · \(status.totalUnstaged, specifier: "%lld") unstaged")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func changesList(_ status: GitStatusFiles) -> some View {
List {
if !status.stagedFiles.isEmpty {
Section("Staged (\(status.stagedFiles.count))") {
ForEach(Array(status.stagedFiles.enumerated()), id: \.offset) { _, file in
GitFileRow(file: file) {
onOpenFile(file.fullPath, true)
}
}
}
}
if !status.unstagedFiles.isEmpty {
Section("Unstaged (\(status.unstagedFiles.count))") {
ForEach(Array(status.unstagedFiles.enumerated()), id: \.offset) { _, file in
GitFileRow(file: file) {
onOpenFile(file.fullPath, false)
}
}
}
}
}
.listStyle(.plain)
}
}
private struct GitFileRow: View {
let file: GitFileStatus
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 1) {
Text(file.fileName)
.font(.subheadline.weight(.medium))
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
if file.linesAdded > 0 || file.linesRemoved > 0 {
Text(counts)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
StatusBadge(status: file.status, dark: colorScheme == .dark)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var subtitle: String {
if let oldPath = file.oldPath {
return "\(oldPath) → \(file.fullPath)"
}
return file.filePath.isEmpty ? String(localized: "Project root") : file.filePath
}
private var counts: String {
var text = ""
if file.linesAdded > 0 { text += "+\(file.linesAdded)" }
if file.linesAdded > 0 && file.linesRemoved > 0 { text += " " }
if file.linesRemoved > 0 { text += "-\(file.linesRemoved)" }
return text
}
}
private struct StatusBadge: View {
let status: GitFileChange
let dark: Bool
var body: some View {
let color = gitStatusColor(status, dark: dark)
Text(gitStatusLetter(status))
.font(.caption2.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(color, lineWidth: 1)
)
}
}
// MARK: - Browse tab
private struct BrowseTabView: View {
let state: FilesModel.BrowseState
let onToggleDirectory: (String) -> Void
let onToggleHidden: (Bool) -> Void
let onOpenFile: (_ path: String) -> Void
var body: some View {
VStack(spacing: 0) {
Toggle(isOn: Binding(get: { state.showHidden }, set: onToggleHidden)) {
Text("Show hidden files")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
List(state.rows) { row in
browseRow(row)
}
.listStyle(.plain)
}
}
@ViewBuilder
private func browseRow(_ row: FilesModel.BrowseRow) -> some View {
switch row {
case .directory(let path, let name, let depth, let isExpanded):
Button {
onToggleDirectory(path)
} label: {
HStack(spacing: 6) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 14)
Image(systemName: "folder")
.font(.subheadline)
.foregroundStyle(.tint)
Text(name)
.font(.subheadline)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.leading, indent(depth))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .file(let path, let name, let depth, let size, let modified):
Button {
onOpenFile(path)
} label: {
VStack(alignment: .leading, spacing: 1) {
Text(name)
.font(.subheadline)
.lineLimit(1)
if let metadata = formatFileMetadata(size: size, modified: modified) {
Text(metadata)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// Files align with sibling directory names (chevron offset).
.padding(.leading, indent(depth) + 20)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .loading(_, let depth):
ProgressView()
.controlSize(.small)
.padding(.leading, indent(depth) + 20)
case .failure(_, let depth, let message):
Text(LocalizedNoticeMapper.map(message))
.font(.caption)
.foregroundStyle(.red)
.padding(.leading, indent(depth) + 20)
}
}
private func indent(_ depth: Int) -> CGFloat {
CGFloat(depth) * 16
}
}
// MARK: - Search tab
private struct SearchTabView: View {
let state: FilesModel.SearchState
let onQueryChange: (String) -> Void
let onOpenFile: (_ path: String) -> Void
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search files…", text: Binding(get: { state.query }, set: onQueryChange))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.vertical, 8)
if state.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
CenteredHint(text: String(localized: "Type to search files in the session directory"))
Spacer(minLength: 0)
} else if state.isLoading {
CenteredProgress()
} else if let error = state.error {
ErrorBanner(message: LocalizedNoticeMapper.map(error))
Spacer(minLength: 0)
} else if state.hasSearched && state.results.isEmpty {
CenteredHint(text: String(localized: "No files matched"))
Spacer(minLength: 0)
} else {
List(state.results, id: \.fullPath) { item in
Button {
onOpenFile(item.fullPath)
} label: {
VStack(alignment: .leading, spacing: 1) {
Text(item.fullPath)
.font(.subheadline)
.lineLimit(1)
.truncationMode(.middle)
if let metadata = formatFileMetadata(size: item.size, modified: item.modified) {
Text(metadata)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.listStyle(.plain)
}
}
}
}
// MARK: - Shared bits
private struct ErrorBanner: View {
let message: String
var body: some View {
Text(message)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.red.opacity(0.12))
}
}
private struct CenteredProgress: View {
var body: some View {
VStack {
ProgressView()
.padding(.top, 48)
Spacer(minLength: 0)
}
}
}
private struct CenteredHint: View {
let text: String
var body: some View {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.vertical, 48)
}
}