Files
web-terminal/ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
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.
2026-07-30 15:58:01 +02:00

560 lines
22 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
import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// C2 web v0.6 / Android
/// - ****`GitSyncBand` / / HEAD /
/// git ""
/// - **Git **stage/commit/push/fetch + `git log` + PR/CI
/// - **worktree **T-iOS-32 / /
///
/// - **`claude --resume` **
///
/// ///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
private let onResumeClaude: (String, String) -> Void
/// worktree prune / removecreate sheet
///
@State private var worktreeActions: WorktreeViewModel
@State private var route: DetailRoute?
@State private var sheet: DetailSheet?
@State private var worktreePendingRemoval: WorktreeInfo?
@State private var isPruneConfirmPresented = false
/// destination `isPresented:`
private enum DetailRoute: Hashable, Identifiable {
case diff
case gitPanel
var id: Self { self }
}
private enum DetailSheet: Hashable, Identifiable {
case newWorktree
case resumeHistory
var id: Self { self }
}
private enum Metrics {
/// CLAUDE.md token
static let claudeMdLineLimit = 40
}
init(
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void,
onResumeClaude: @escaping (String, String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
self.onResumeClaude = onResumeClaude
_worktreeActions = State(initialValue: .forProject(
endpoint: endpoint, http: http, path: viewModel.path
))
}
var body: some View {
content
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(item: $route) { destination(for: $0) }
.sheet(item: $sheet) { presentedSheet(for: $0) }
}
/// worktree prune remove
/// 409 `content` `body`
/// `body`
@ViewBuilder private var content: some View {
phaseContent
.confirmationDialog(
WorktreeCopy.pruneConfirmTitle,
isPresented: $isPruneConfirmPresented,
titleVisibility: .visible
) {
Button(WorktreeCopy.pruneConfirm, role: .destructive) {
Task {
await worktreeActions.prune()
await viewModel.load()
}
}
} message: {
Text(WorktreeCopy.pruneConfirmMessage)
}
.confirmationDialog(
WorktreeCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: worktreePendingRemoval
) { worktree in
Button(WorktreeCopy.removeConfirm, role: .destructive) {
remove(worktree)
}
} message: { worktree in
Text(verbatim: WorktreeCopy.removeConfirmMessage(worktree.path))
}
.confirmationDialog(
WorktreeCopy.forceConfirmTitle,
isPresented: forceDialogBinding,
titleVisibility: .visible
) {
// 409****
Button(WorktreeCopy.forceConfirm, role: .destructive) {
Task {
await worktreeActions.confirmForceRemove()
await viewModel.load()
}
}
Button(WorktreeCopy.cancel, role: .cancel) {
worktreeActions.cancelForceRemove()
}
} message: {
Text(WorktreeCopy.forceConfirmMessage)
}
}
@ViewBuilder private func destination(for route: DetailRoute) -> some View {
switch route {
case .diff:
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
case .gitPanel:
GitPanelScreen(endpoint: endpoint, http: http, path: viewModel.path)
}
}
@ViewBuilder private func presentedSheet(for sheet: DetailSheet) -> some View {
switch sheet {
case .newWorktree:
WorktreeSheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onCreated: { Task { await viewModel.load() } },
onOpenSession: onOpenClaude
)
case .resumeHistory:
ResumeHistorySheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onResume: onResumeClaude
)
}
}
/// worktree
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { worktreePendingRemoval != nil },
set: { presented in if !presented { worktreePendingRemoval = nil } }
)
}
/// 409 force
private var forceDialogBinding: Binding<Bool> {
Binding(
get: {
if case .forceConfirming = worktreeActions.removePhase { return true }
return false
},
set: { presented in if !presented { worktreeActions.cancelForceRemove() } }
)
}
private func remove(_ worktree: WorktreeInfo) {
Task {
await worktreeActions.remove(worktreePath: worktree.path)
if case .removed = worktreeActions.removePhase {
DS.Haptics.warning()
await viewModel.load()
}
}
}
// MARK: - Phase switch
@ViewBuilder private var phaseContent: 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)
.tint(DS.Palette.accent)
}
}
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)
worktreesSection(detail)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
}
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: detail.name)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
// verbatim +
Text(verbatim: detail.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: DS.Space.sm8) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if detail.dirty == true {
DirtyBadge()
}
}
// w6/G3 git
if let band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount,
nowMs: Date().timeIntervalSince1970 * 1_000
) {
SyncSummaryChips(band: band)
}
}
}
}
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
DS.Haptics.selection()
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.listRowInsets(EdgeInsets(
top: DS.Space.sm8, leading: DS.Space.lg16,
bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
if detail.isGit {
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
route = .gitPanel
}
secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") {
route = .diff
}
}
}
}
private func secondaryAction(
_ title: String, symbol: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: symbol)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
@ViewBuilder private func sessionsSection(_ detail: ProjectDetail) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if detail.sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(detail.sessions, id: \.id) { session in
sessionRow(session)
}
}
// `claude --resume <id>`T-iOS-32
Button {
sheet = .resumeHistory
} label: {
Label(ResumeCopy.entryLabel, systemImage: "clock.arrow.circlepath")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: DS.Space.sm8) {
// = + + VoiceOver DS 退
StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.font(DS.Typography.body)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
// tabular
Text(ProjectDetailCopy.clientCount(session.clientCount))
.dsMetaText()
}
}
}
// MARK: - WorktreesT-iOS-32 + + +
/// git worktree """"
/// w6/G5
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
if detail.isGit {
Section {
ForEach(detail.worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
Button {
sheet = .newWorktree
} label: {
Label(WorktreeCopy.sheetTitle, systemImage: "plus.rectangle.on.folder")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
worktreeFeedback
} header: {
worktreeHeader(detail)
} footer: {
if detail.worktrees.contains(where: WorktreeViewModel.canRemove) {
Text(ProjectDetailCopy.worktreeSwipeHint)
}
}
}
}
private func worktreeHeader(_ detail: ProjectDetail) -> some View {
HStack {
Text(ProjectDetailCopy.worktreesHeader)
Spacer(minLength: DS.Space.sm8)
// prunable
if detail.worktrees.contains(where: { $0.prunable == true }) {
Button(WorktreeCopy.pruneButton) {
isPruneConfirmPresented = true
}
.font(DS.Typography.caption.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minHeight: DS.Layout.minHitTarget)
.disabled(worktreeActions.isBusy)
}
}
}
/// prune / remove
@ViewBuilder private var worktreeFeedback: some View {
switch worktreeActions.prunePhase {
case .done(let message):
InlineMessage(text: message, tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .pruning:
EmptyView()
}
switch worktreeActions.removePhase {
case .removed(let path):
InlineMessage(text: WorktreeCopy.removed(path), tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .removing, .forceConfirming:
EmptyView()
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if worktree.isMain {
TagBadge(text: ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
TagBadge(text: ProjectDetailCopy.worktreeLocked)
}
if worktree.prunable == true {
TagBadge(text: ProjectDetailCopy.worktreePrunable)
}
}
Text(verbatim: worktree.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if worktree.locked == true {
Text(WorktreeCopy.lockedHint)
.dsMetaText()
}
}
.swipeActions(edge: .trailing) {
// worktree 400/409
if WorktreeViewModel.canRemove(worktree) {
Button(WorktreeCopy.removeButton, role: .destructive) {
worktreePendingRemoval = worktree
}
.accessibilityLabel(WorktreeCopy.removeAccessibilityLabel)
}
}
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
}
// MARK: - DS token Projects/
/// + TelemetryChip `.quaternary`
struct DirtyBadge: View {
var body: some View {
Text(ProjectsCopy.dirtyBadge)
.font(DS.Typography.caption.weight(.medium))
// amber + amber
.foregroundStyle(DS.Palette.statusWaiting)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(DS.Palette.statusWaiting.opacity(0.18), in: Capsule())
}
}
/// worktree ///accent
struct TagBadge: View {
let text: String
var body: some View {
Text(text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.overlay(
Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
)
}
}
// 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 worktreePrunable = "可回收"
static let worktreeSwipeHint = "左滑一行可删除该 worktree主 worktree 与已锁定的不可删)。"
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) 个客户端"
}
}