业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多: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>
227 lines
10 KiB
Swift
227 lines
10 KiB
Swift
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()
|
||
}
|
||
}
|