T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView (NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero regression); privacy shade hoisted to shared ZStack top for both branches T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present), pointer context menu (copy/new-in-cwd/kill, all via existing channels) T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through + AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered by SidebarSelectionTests) Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10; zero changes under ios/Packages, src/, public/
282 lines
11 KiB
Swift
282 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 Metrics {
|
||
static let rowSpacing: CGFloat = 2
|
||
static let chipSpacing: CGFloat = 6
|
||
// T-iPad-4 · 多列网格专属度量 —— 只走 iPad(.pad)分支,iPhone 不受影响。
|
||
static let gridSectionSpacing: CGFloat = 12
|
||
static let gridPadding: CGFloat = 16
|
||
static let gridCardVerticalPadding: CGFloat = 8
|
||
static let gridCardHorizontalPadding: CGFloat = 10
|
||
static let gridCardCornerRadius: CGFloat = 10
|
||
}
|
||
|
||
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 {
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: Metrics.gridSectionSpacing) {
|
||
errorRows
|
||
if let message = viewModel.emptyStateMessage {
|
||
Text(message)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
ForEach(viewModel.groups) { group in
|
||
gridSection(group, columns: columns)
|
||
}
|
||
}
|
||
.padding(Metrics.gridPadding)
|
||
}
|
||
.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: Metrics.rowSpacing) {
|
||
if group.kind != .flat {
|
||
groupHeader(group, isCollapsed: isCollapsed)
|
||
.padding(.top, Metrics.gridSectionSpacing)
|
||
}
|
||
if !isCollapsed {
|
||
LazyVGrid(
|
||
columns: gridColumns(columns),
|
||
alignment: .leading,
|
||
spacing: Metrics.chipSpacing
|
||
) {
|
||
ForEach(group.projects, id: \.path) { project in
|
||
projectRow(project, group: group)
|
||
.padding(.vertical, Metrics.gridCardVerticalPadding)
|
||
.padding(.horizontal, Metrics.gridCardHorizontalPadding)
|
||
.background(
|
||
.quaternary,
|
||
in: RoundedRectangle(cornerRadius: Metrics.gridCardCornerRadius)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 等宽弹性列(`columns >= 1` 由 `ProjectsGridLayout` 保证,绝不空网格)。
|
||
private func gridColumns(_ count: Int) -> [GridItem] {
|
||
Array(
|
||
repeating: GridItem(.flexible(), spacing: Metrics.chipSpacing),
|
||
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(.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
|
||
}
|