import APIClient import SwiftUI /// T-iOS-26 · Projects 列表屏(镜像 web v0.6 projects 面板):namespace 分组 /// 折叠分区 + 收藏星标 + dirty 徽标 + Active now 置顶 + 搜索。纯呈现 —— /// 分组/收藏/折叠/导航信号全部是 `ProjectsViewModel` 逻辑(单测覆盖)。 /// /// 安全:项目名/路径/分支是**不可信服务器字节** —— 一律 `Text(verbatim:)` /// (绝不 LocalizedStringKey/Markdown),单行截断。 struct ProjectsScreen: View { @Bindable var viewModel: ProjectsViewModel /// "在此仓库开新会话" 导航钩子(AppCoordinator.openProject 消费)。 var onOpen: (ProjectOpenRequest) -> Void = { _ in } private enum Metrics { static let rowSpacing: CGFloat = 2 static let chipSpacing: CGFloat = 6 } var body: some View { list .navigationTitle(ProjectsCopy.title) .navigationBarTitleDisplayMode(.inline) .searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt) .refreshable { await viewModel.refresh() } .task { await viewModel.load() } .onChange(of: viewModel.openRequest) { _, request in guard let request else { return } onOpen(request) } .navigationDestination(for: ProjectRoute.self) { route in ProjectDetailScreen( viewModel: viewModel.makeDetailViewModel(path: route.path), endpoint: viewModel.host.endpoint, http: viewModel.http, onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) } ) } } // MARK: - List private var list: some View { List { errorRows if let message = viewModel.emptyStateMessage { Text(message) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .listRowSeparator(.hidden) } ForEach(viewModel.groups) { group in groupSection(group) } } .listStyle(.insetGrouped) .overlay { if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil { ProgressView() } } } /// 显式错误行(刷新失败留旧列表、prefs 失败降级本地 —— 都要可见)。 @ViewBuilder private var errorRows: some View { ForEach( [ viewModel.fetchErrorMessage, viewModel.prefsErrorMessage, viewModel.prefsSyncErrorMessage, viewModel.openErrorMessage, ].compactMap(\.self), id: \.self ) { message in Label(message, systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.orange) .listRowSeparator(.hidden) } } // MARK: - Group section(flat 无 chrome;namespace/other 可折叠) @ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View { let isCollapsed = viewModel.isCollapsed(group) Section { if !isCollapsed { ForEach(group.projects, id: \.path) { project in projectRow(project, group: group) } } } header: { if group.kind != .flat { groupHeader(group, isCollapsed: isCollapsed) } } } private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View { HStack(spacing: Metrics.chipSpacing) { if group.isCollapsible { Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") .font(.caption2) } // namespace label 来自服务器仓库名 → verbatim。 Text(verbatim: group.label) .lineLimit(1) Text(verbatim: "\(group.projects.count)") .foregroundStyle(.secondary) // 折叠的分区绝不悄悄埋掉活跃会话(镜像 web 组头徽标)。 if group.kind != .active && group.activeCount > 0 { Text(ProjectsCopy.activeCountBadge(group.activeCount)) .font(.caption2) .foregroundStyle(.green) } Spacer(minLength: 0) } .contentShape(Rectangle()) .onTapGesture { guard group.isCollapsible else { return } Task { await viewModel.toggleCollapsed(key: group.key) } } .accessibilityAddTraits(group.isCollapsible ? .isButton : []) } // MARK: - Project row private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View { NavigationLink(value: ProjectRoute(path: project.path)) { HStack(spacing: Metrics.chipSpacing) { favouriteButton(project) VStack(alignment: .leading, spacing: Metrics.rowSpacing) { Text(verbatim: ProjectGrouping.displayLabel( name: project.name, groupKey: group.key )) .font(.body.weight(.medium)) .lineLimit(1) projectChips(project) } Spacer(minLength: 0) if ProjectGrouping.hasRunningSession(project) { Image(systemName: "circle.fill") .font(.caption2) .foregroundStyle(.green) .accessibilityLabel(ProjectsCopy.activeCountBadge(1)) } } } } private func favouriteButton(_ project: ProjectInfo) -> some View { Button { Task { await viewModel.toggleFavourite(path: project.path) } } label: { Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star") .foregroundStyle(.yellow) } .buttonStyle(.borderless) // List 行内独立可点 } @ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View { HStack(spacing: Metrics.chipSpacing) { if let branch = project.branch { Label { Text(verbatim: branch).lineLimit(1) } icon: { Image(systemName: "arrow.triangle.branch") } .font(.caption) .foregroundStyle(.secondary) } if project.dirty == true { Text(ProjectsCopy.dirtyBadge) .font(.caption2) .foregroundStyle(.orange) } } } } /// 详情屏的 push 路由值(value-based navigation)。 struct ProjectRoute: Hashable { let path: String }