Files
web-terminal/ios/App/WebTerm/Screens/ProjectsScreen.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
2026-07-05 16:15:57 +02:00

185 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 sectionflat chromenamespace/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
}