Files
terminalX/apps/TerminalX/iOS/HostsView.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00

227 lines
10 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 SwiftUI
// HostEditorView Termius `HostsListView`/`HostCard`/`SessionStatusRow`
// + 稿
//
/// + = Keychain / + / Tailscale+
struct HostEditorView: View {
let existing: SavedHost?
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
///
var existingGroups: [String] = []
let onSave: (SavedHost) -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
private enum AuthMode: Hashable { case inline, credential }
@State private var name = ""
@State private var host = ""
@State private var port = "22"
@State private var authMode: AuthMode = .inline
@State private var inlineUser = ""
@State private var inlinePassword = ""
@State private var selectedCredentialID: UUID?
@State private var tailscaleID: UUID?
@State private var useMosh = false
@State private var showNewCredential = false
@State private var showNewTailscale = false
/// chips
@State private var group = ""
@State private var os = ""
@State private var useTmux = true
@State private var tmuxSession = ""
private var p: TXPalette { theme.palette }
private var canSave: Bool {
guard !host.isEmpty else { return false }
switch authMode {
case .inline: return !inlineUser.isEmpty
case .credential: return selectedCredentialID != nil
}
}
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("基本") {
TXTextField(title: "名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
TXTextField(title: "主机 / IP", text: $host)
TXTextField(title: "端口", text: $port, keyboard: .numberPad)
}
Section {
TXTextField(title: "分组", text: $group, placeholder: "生产 / 家里 / 云(可空)")
if !existingGroups.isEmpty {
// chips
HStack(spacing: TX.Space.xs) {
ForEach(existingGroups, id: \.self) { g in
TXChip(text: g, isSelected: group == g) {
group = (group == g) ? "" : g
}
}
}
.txRow(p)
}
TXTextField(title: "系统", text: $os, placeholder: "macOS · M4 Max可空")
} header: {
Text("归类(首页展示用)")
} footer: {
Text("分组决定首页「全部主机」的筛选 chips系统只作卡片副行展示不参与连接。")
}
authSection
networkSection
Section {
Toggle("自动进入 tmux", isOn: $useTmux).tint(p.accent).txRow(p)
if useTmux {
TXTextField(title: "tmux 会话名", text: $tmuxSession, placeholder: "main")
}
Toggle("使用 mosh", isOn: $useMosh).tint(p.accent).txRow(p)
} header: {
Text("会话")
} footer: {
Text(useTmux
? "登录后执行 tmux -CC new -A -s <会话名>:同名会话存在就接回,否则新建。服务器没装 tmux 会自动降级为客户端窗口并给安装引导。"
: "不进 tmux会话由 App 维持,关掉 App 后服务端不会继续跑mosh 仍能兜断线)。")
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(existing == nil ? "添加主机" : "编辑主机")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showNewCredential) {
CredentialEditorView(existing: nil, store: credStore) { newCred in
selectedCredentialID = newCred.id
authMode = .credential
}
}
.sheet(isPresented: $showNewTailscale) {
// Tailscale
TailscaleEditorView(existing: nil, store: tailscaleStore) { conn in
tailscaleID = conn.id
}
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
@ViewBuilder private var authSection: some View {
Section {
Picker("方式", selection: $authMode) {
Text("直接输入").tag(AuthMode.inline)
Text("从 Keychain 选择").tag(AuthMode.credential)
}
.pickerStyle(.segmented)
.txRow(p)
if authMode == .inline {
TXTextField(title: "用户名", text: $inlineUser)
TXTextField(title: "密码", text: $inlinePassword, secure: true)
} else {
Picker("凭据", selection: $selectedCredentialID) {
Text("未选择").tag(UUID?.none)
ForEach(credStore.credentials) { c in
Text("\(c.name) · \(c.username)").tag(UUID?.some(c.id))
}
}
.txRow(p)
Button { showNewCredential = true } label: {
Label("新建凭据", systemImage: "plus.circle")
}
.txRow(p)
}
} header: {
Text("认证")
} footer: {
if authMode == .inline {
Text("保存时自动存为 Keychain 凭据(可在 设置 → Keychain 里管理),主机只保留引用。")
}
}
}
@ViewBuilder private var networkSection: some View {
Section("网络(出口)") {
Picker("经由", selection: $tailscaleID) {
Text("直连").tag(UUID?.none)
ForEach(tailscaleStore.connections) { conn in
Text(conn.name).tag(UUID?.some(conn.id))
}
}
.txRow(p)
Button { showNewTailscale = true } label: {
Label("新建 Tailscale 连接", systemImage: "plus.circle")
}
.txRow(p)
if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey {
Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。")
.font(.system(size: 12)).foregroundStyle(p.warning).txRow(p)
}
}
}
private func populate() {
guard let h = existing else { return }
name = h.name; host = h.host; port = String(h.port)
useMosh = h.useMosh; tailscaleID = h.tailscaleID
group = h.group ?? ""; os = h.os ?? ""
useTmux = h.useTmux; tmuxSession = h.tmuxSession ?? ""
switch h.auth {
case .inlinePassword(let u, let pw):
authMode = .inline; inlineUser = u; inlinePassword = pw
case .credential(let id):
authMode = .credential; selectedCredentialID = id
}
}
private func save() {
let auth: SavedHost.AuthRef
switch authMode {
case .inline:
// ** Keychain **
// 稿`inlinePassword`
// /fixture
//
if let dup = credStore.credentials.first(where: { c in
c.kind == .password && c.username == inlineUser
&& credStore.secret(for: c.id) == .password(inlinePassword)
}) {
auth = .credential(id: dup.id)
} else {
let cred = Credential(name: "\(inlineUser)@\(name.isEmpty ? host : name)",
username: inlineUser, kind: .password)
credStore.upsert(cred, secret: .password(inlinePassword))
auth = .credential(id: cred.id)
}
case .credential:
guard let id = selectedCredentialID else { return }
auth = .credential(id: id)
}
let saved = SavedHost(
id: existing?.id ?? UUID(),
name: name.isEmpty ? host : name,
host: host, port: Int(port) ?? 22,
useMosh: useMosh, useTmux: useTmux,
tmuxSession: tmuxSession.isEmpty ? nil : tmuxSession.trimmingCharacters(in: .whitespaces),
auth: auth, tailscaleID: tailscaleID,
group: group.isEmpty ? nil : group.trimmingCharacters(in: .whitespaces),
os: os.isEmpty ? nil : os.trimmingCharacters(in: .whitespaces))
onSave(saved)
dismiss()
}
}