mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
Translate the unknown delivery state, keep decorative content verbatim, and pin integer interpolation formats to existing catalog keys.
427 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|