App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
292 lines
12 KiB
Swift
292 lines
12 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-iOS-32(C2):历史会话恢复走同一条开会话缝。
|
||
onResumeClaude: { cwd, sessionId in
|
||
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
|
||
}
|
||
)
|
||
}
|
||
// 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
|
||
}
|