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
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,286 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@State private var viewModel: ProjectDetailViewModel
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private enum Metrics {
static let headerSpacing: CGFloat = 4
static let chipSpacing: CGFloat = 6
static let claudeMdLineLimit = 40
}
init(
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
}
var body: some View {
content
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let failure):
failedState(failure)
case .loaded(let detail):
detailList(detail)
}
}
/// 400/404/500 `{error}` Steps
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(ProjectDetailCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
}
static func failureCopy(
_ failure: ProjectDetailViewModel.Failure
) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (ProjectDetailCopy.failedPathInvalid,
ProjectDetailCopy.failedPathInvalidDetail)
case .notFound:
return (ProjectDetailCopy.failedNotFound,
ProjectDetailCopy.failedNotFoundDetail)
case .unavailable:
return (ProjectDetailCopy.failedUnavailable,
ProjectDetailCopy.failedUnavailableDetail)
}
}
// MARK: - Loaded
private func detailList(_ detail: ProjectDetail) -> some View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
}
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
Text(verbatim: detail.name)
.font(.headline)
.lineLimit(1)
Text(verbatim: detail.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: Metrics.chipSpacing) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
}
if detail.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
}
}
}
}
}
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
}
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(sessions, id: \.id) { session in
sessionRow(session)
}
}
}
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: Self.statusGlyph(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(ProjectDetailCopy.clientCount(session.clientCount))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// web claudeIconpublic/tabs.ts:74-80
static func statusGlyph(_ status: ClaudeStatus) -> String {
switch status {
case .working: return ""
case .waiting: return ""
case .idle: return ""
case .stuck: return ""
case .unknown: return ""
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
}
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(.callout)
.lineLimit(1)
if worktree.isMain {
badge(ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
badge(ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
badge(ProjectDetailCopy.worktreeLocked)
}
}
Text(verbatim: worktree.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.blue)
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(.caption.monospaced())
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
}
// MARK: - plan §4
enum ProjectDetailCopy {
static let title = "项目详情"
static let openClaude = "在此仓库开新会话"
static let viewDiff = "查看代码差异"
static let sessionsHeader = "运行中的会话"
static let noSessions = "暂无运行中的会话。"
static let sessionExited = "已退出"
static let worktreesHeader = "Worktrees"
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
static let retry = "重试"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不在主机的项目根目录下或已被移除404"
static let failedUnavailable = "详情加载失败"
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
static func clientCount(_ count: Int) -> String {
"\(count) 个客户端"
}
}