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.
This commit is contained in:
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · 新建 Worktree(`POST /projects/worktree`,G)。
|
||||
///
|
||||
/// 表单只有两格:新分支名(必填、即时校验)+ 起点 ref(留空 = 当前 HEAD)。
|
||||
/// 成功后展示服务器给的 canonical 路径/分支,并提供"在新 Worktree 开会话"——
|
||||
/// 这就是 "spin up a worktree, land the winner, delete the rest" 循环的入口。
|
||||
///
|
||||
/// 安全:分支名先过 `WorktreeBranchRule`(校验先于网络);服务器返回的路径/分支/
|
||||
/// 错误文案都是不可信字节 → `Text(verbatim:)`。
|
||||
struct WorktreeSheet: View {
|
||||
@State private var viewModel: WorktreeViewModel
|
||||
/// 创建成功(详情屏据此刷新 worktree 列表)。
|
||||
let onCreated: () -> Void
|
||||
/// "在新 worktree 开会话":把服务器给的 canonical 路径交回详情屏的开会话钩子。
|
||||
let onOpenSession: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(
|
||||
viewModel: WorktreeViewModel,
|
||||
onCreated: @escaping () -> Void,
|
||||
onOpenSession: @escaping (String) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.onCreated = onCreated
|
||||
self.onOpenSession = onOpenSession
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var bindable = viewModel
|
||||
return NavigationStack {
|
||||
Form {
|
||||
if case .created(let path, let branch) = viewModel.createPhase {
|
||||
createdSection(path: path, branch: branch)
|
||||
} else {
|
||||
formSection(
|
||||
branch: $bindable.branchText, base: $bindable.baseText
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(WorktreeCopy.sheetTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func formSection(
|
||||
branch: Binding<String>, base: Binding<String>
|
||||
) -> some View {
|
||||
Section {
|
||||
TextField(WorktreeCopy.branchField, text: branch)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.done)
|
||||
.onSubmit { submit() }
|
||||
TextField(WorktreeCopy.baseField, text: base)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} footer: {
|
||||
if let message = viewModel.branchValidationMessage {
|
||||
Text(message)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
submit()
|
||||
} label: {
|
||||
if viewModel.createPhase == .creating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
} else {
|
||||
Label(WorktreeCopy.submit, systemImage: "plus.rectangle.on.folder")
|
||||
}
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(!viewModel.canSubmitCreate)
|
||||
.listRowBackground(Color.clear)
|
||||
if case .failed(let message) = viewModel.createPhase {
|
||||
InlineMessage(text: message, tone: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func createdSection(path: String, branch: String) -> some View {
|
||||
Section(WorktreeCopy.createdTitle) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
Text(verbatim: branch)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(verbatim: path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
onOpenSession(path)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label(WorktreeCopy.openInNewWorktree, systemImage: "terminal.fill")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(path.isEmpty)
|
||||
.listRowBackground(Color.clear)
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
Task {
|
||||
await viewModel.create()
|
||||
if case .created = viewModel.createPhase {
|
||||
DS.Haptics.success()
|
||||
onCreated() // 详情屏刷新(新 worktree 立刻出现在列表里)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user