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() } }