Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
288 lines
11 KiB
Swift
288 lines
11 KiB
Swift
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
|
||
}
|