T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
185 lines
6.8 KiB
Swift
185 lines
6.8 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 }
|
||
|
||
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
|
||
}
|