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 } /// T-iPad-4 · 设备 idiom 只在此读一次,交给 `ProjectsGridLayout`/ /// `ProjectsSheetSizing` 决策(视图里零散落条件)。iPhone → 现有单列 List /// (字节级不变);iPad → 多列网格 + 卡片式 sheet detents。idiom 是设备常量、 /// 运行期不变,故非 @Environment 即可。见 `ProjectsLayout` 注释解释为何用 /// idiom 而非 size class(iPad 表单 sheet 内部恒 compact)。 private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private enum Copy { static let emptyNoProjectsTitle = "暂无项目" static let emptyNoMatchTitle = "无匹配项目" } var body: some View { adaptiveContent .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) } ) } // T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套 // 卡片式 detents(不铺满大屏)。 .adaptiveProjectsSheetDetents(idiom: idiom) } // MARK: - 自适应容器(唯一 idiom 消费点,经 ProjectsGridLayout) /// iPhone → 现有单列 `list`(原样复用,字节级零回归);iPad → 多列 /// `gridList`。分组/折叠/收藏/prefs 往返全部是同一 `ProjectsViewModel` /// 逻辑,两条路径只换视觉容器。 @ViewBuilder private var adaptiveContent: some View { if ProjectsGridLayout.usesGrid(idiom: idiom) { gridList } else { list } } // MARK: - List private var list: some View { List { errorRows if let message = viewModel.emptyStateMessage { emptyState(message) .frame(maxWidth: .infinity) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } ForEach(viewModel.groups) { group in groupSection(group) } } .listStyle(.insetGrouped) .overlay { if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil { ProgressView() } } } /// 空态:区分「无项目」与「无搜索结果」(symbol + 文案;friendly,非错误)。 private func emptyState(_ message: String) -> some View { ContentUnavailableView { Label( viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle, systemImage: viewModel.isSearching ? "magnifyingglass" : "folder" ) } description: { Text(message) } } // MARK: - Grid(regular 宽度:多列网格) /// iPad 分栏/大屏下的多列网格。列数由 `ProjectsGridLayout.columnCount` 按 /// **可用宽度**(GeometryReader)决定;分组头/折叠/收藏/行渲染全部复用 List /// 路径的同名 builder(`groupHeader`/`projectRow`/`errorRows`)—— 只换外层 /// 容器,分组/收藏/prefs 往返逻辑零改。 private var gridList: some View { GeometryReader { proxy in let columns = ProjectsGridLayout.columnCount( availableWidth: proxy.size.width, idiom: idiom ) ScrollView { LazyVStack(alignment: .leading, spacing: DS.Space.md12) { errorRows if let message = viewModel.emptyStateMessage { emptyState(message) .frame(maxWidth: .infinity) } ForEach(viewModel.groups) { group in gridSection(group, columns: columns) } } .padding(DS.Space.lg16) } .overlay { if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil { ProgressView() } } } } @ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View { let isCollapsed = viewModel.isCollapsed(group) VStack(alignment: .leading, spacing: DS.Space.sm8) { if group.kind != .flat { groupHeader(group, isCollapsed: isCollapsed) .padding(.top, DS.Space.md12) } if !isCollapsed { LazyVGrid( columns: gridColumns(columns), alignment: .leading, spacing: DS.Space.sm8 ) { ForEach(group.projects, id: \.path) { project in // 卡片 = DS Card(secondary 底 + 发丝描边 + md12)。 Card(padding: DS.Space.md12) { projectRow(project, group: group) } } } } } } /// 等宽弹性列(`columns >= 1` 由 `ProjectsGridLayout` 保证,绝不空网格)。 private func gridColumns(_ count: Int) -> [GridItem] { Array( repeating: GridItem(.flexible(), spacing: DS.Space.sm8), count: max(count, ProjectsGridLayout.singleColumn) ) } /// 显式错误行(刷新失败留旧列表、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(DS.Typography.caption) .foregroundStyle(DS.Palette.statusWaiting) .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: DS.Space.sm8) { if group.isCollapsible { Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } // namespace label 来自服务器仓库名 → verbatim。 Text(verbatim: group.label) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) // 计数含数字 → 等宽 tabular。 Text(verbatim: "\(group.projects.count)") .font(DS.Typography.metaMono) .foregroundStyle(DS.Palette.textTertiary) // 折叠的分区绝不悄悄埋掉活跃会话(镜像 web 组头徽标)。 if group.kind != .active && group.activeCount > 0 { Text(ProjectsCopy.activeCountBadge(group.activeCount)) .font(DS.Typography.metaMono) .foregroundStyle(DS.Palette.statusWorking) } 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: DS.Space.sm8) { favouriteButton(project) VStack(alignment: .leading, spacing: DS.Space.xs2) { Text(verbatim: ProjectGrouping.displayLabel( name: project.name, groupKey: group.key )) .font(DS.Typography.body.weight(.medium)) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) projectChips(project) } Spacer(minLength: 0) // 有运行中会话 → DS 状态徽标(色 + 形 + VoiceOver,非仅靠颜色)。 if ProjectGrouping.hasRunningSession(project) { StatusBadge(status: .working) } } } } private func favouriteButton(_ project: ProjectInfo) -> some View { Button { DS.Haptics.selection() Task { await viewModel.toggleFavourite(path: project.path) } } label: { let isFav = viewModel.isFavourite(project.path) Image(systemName: isFav ? "star.fill" : "star") .foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary) } .buttonStyle(.borderless) // List 行内独立可点 } @ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View { HStack(spacing: DS.Space.sm8) { if let branch = project.branch { Label { Text(verbatim: branch).lineLimit(1) } icon: { Image(systemName: "arrow.triangle.branch") } .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } if project.dirty == true { DirtyBadge() } } } } /// 详情屏的 push 路由值(value-based navigation)。 struct ProjectRoute: Hashable { let path: String }