import SwiftUI /// 首页 · 主机管理(设计稿 `1e 主机-会话卡`)。 /// /// App 冷启动落点。首要动作是**「回到刚才那个会话」**,所以活动会话卡在最上面、占最大面积; /// 「全部主机」在下面按内容高度铺 4 列。左右留白 40pt。 struct HomeView: View { @ObservedObject var manager: SessionManager @ObservedObject var router: AppRouter @ObservedObject var hostStore: HostStore @ObservedObject var credStore: FileCredentialStore @ObservedObject var tailscaleStore: TailscaleStore @EnvironmentObject var theme: TXThemeManager @Environment(\.horizontalSizeClass) private var hSize @State private var search = "" @State private var groupFilter: String? = nil @State private var showEditor = false @State private var editing: SavedHost? @State private var showSettings = false /// 待关闭确认的会话(会话卡 ✕)。与终端页红点共用同一个 `CloseConfirmDialog`。 @State private var closing: TerminalSession? private var p: TXPalette { theme.palette } /// 分组 chips:「全部」+ 主机里出现过的分组(按首次出现顺序,不排序以免抖动)。 private var groups: [String] { var seen: [String] = [] for h in hostStore.hosts { if let g = h.group, !seen.contains(g) { seen.append(g) } } return seen } private var filteredHosts: [SavedHost] { hostStore.hosts.filter { h in let matchesGroup = groupFilter == nil || h.group == groupFilter guard matchesGroup else { return false } guard !search.isEmpty else { return true } let q = search.lowercased() return h.name.lowercased().contains(q) || h.host.lowercased().contains(q) || (h.os?.lowercased().contains(q) ?? false) } } var body: some View { // 量一次安全区:滚动内容要铺到物理底边(见下)。 TXScreenMetrics { geo in content(safeBottom: geo.safeAreaInsets.bottom) } .background(p.bg.ignoresSafeArea()) // 首页可见时轮询会话内容做文本镜像(缩略卡「真实回滚」的落地方式)。 .task(id: manager.sessions.count) { while !Task.isCancelled { for s in manager.sessions { s.captureSnapshot() } try? await Task.sleep(nanoseconds: 1_500_000_000) } } .sheet(isPresented: $showEditor) { HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore, existingGroups: groups) { host in if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) } else { hostStore.add(host) } } } .sheet(isPresented: $showSettings) { SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore) } // 会话卡 ✕ 的二次确认(与终端页红点同一对话框、同一套动作语义)。 .overlay { if let s = closing { CloseConfirmDialog( session: s, onDetach: { closeFromHome(s, kill: false) }, onKill: { closeFromHome(s, kill: true) }, onCancel: { withAnimation(TX.Motion.standard) { closing = nil } }) .transition(TX.Motion.pop) } } } /// 执行关闭(与 `TerminalScreen.closeSession` 同语义):tmux 默认仅 detach(服务端继续跑), /// `kill=true` 才 kill-session;原生窗口直接 teardown。给命令 250ms 落地再拆传输层。 private func closeFromHome(_ s: TerminalSession, kill: Bool) { withAnimation(TX.Motion.standard) { closing = nil } if let tmux = s.tmuxController { if kill { tmux.killSession() } else { tmux.detach() } } Task { @MainActor in try? await Task.sleep(nanoseconds: 250_000_000) manager.close(s.id) } } private func content(safeBottom: CGFloat) -> some View { VStack(alignment: .leading, spacing: 0) { header if hostStore.hosts.isEmpty && manager.sessions.isEmpty { firstRunEmptyState } else { ScrollView { VStack(alignment: .leading, spacing: TX.Space.m) { // 点主机后先留在首页探测 tmux → 这里给出进度,不让用户对着不动的界面等。 ForEach(manager.connectingSessions) { s in ConnectingRow(session: s) { manager.close(s.id) } } if liveSessions.isEmpty { if manager.connectingSessions.isEmpty { noSessionHint } } else { TXSectionLabel(text: "活动会话") .padding(.top, TX.Space.xs) sessionCards } HStack(alignment: .center, spacing: TX.Space.s) { TXSectionLabel(text: "全部主机") Spacer() if !groups.isEmpty { chips } // 搜索就近放在它作用的列表旁(用户裁决:从页头移到这里)。 TXSearchField(text: $search, placeholder: "搜索主机") .frame(minWidth: 120, maxWidth: 220) } .padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s) if filteredHosts.isEmpty { noMatchState } else { hostGrid } } .padding(.horizontal, gutter) // 末行要能完整滚到安全区之上(否则最后一排主机卡永远被 home indicator 压着)。 .padding(.bottom, TX.Space.xxl + safeBottom) } // 列表内容从物理底边流出去(系统滚动视图的标准观感),别在安全区上方硬切一刀。 .ignoresSafeArea(edges: .bottom) // 会话卡/进度行的增减跟着同一条曲线,避免连上/断开时整页跳一下。 .animation(TX.Motion.standard, value: manager.sessions.count) } } } // MARK: - 空状态 /// 首次运行:一台主机都没有。给一条明确的下一步,而不是空网格。 private var firstRunEmptyState: some View { VStack(spacing: TX.Space.m) { Spacer() Image(systemName: "terminal") .font(.system(size: 40, weight: .ultraLight)) .foregroundStyle(TXAccent.mid) VStack(spacing: 6) { Text("还没有主机") .font(TX.Font.mono(15, .medium)) .foregroundStyle(p.text) Text("添加一台服务器就能开始:SSH 直连,或经 Tailscale 出口访问内网。\n开启 mosh 后断线自动接回。") .font(TX.Font.mono(12)) .foregroundStyle(p.textWeak) .multilineTextAlignment(.center) .lineSpacing(4) } HStack(spacing: TX.Space.s) { TXButton(title: "新建连接", emphasized: true) { editing = nil showEditor = true } TXButton(title: "先配置 Tailscale") { showSettings = true } } .padding(.top, TX.Space.xs) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// 有主机但没有会话:不留白,提示从下面的主机开始。 private var noSessionHint: some View { HStack(spacing: TX.Space.s) { Image(systemName: "arrow.down") .font(.system(size: 12)) .foregroundStyle(p.textWeak) Text("还没有在跑的会话 —— 点下面任意主机即可连接") .font(TX.Font.mono(12)) .foregroundStyle(p.textWeak) } .padding(.horizontal, TX.Space.s) .frame(height: 40) .frame(maxWidth: .infinity, alignment: .leading) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.card) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .foregroundStyle(p.divider) ) .padding(.top, TX.Space.xs) } /// 搜索/筛选无命中:说明命中为空的原因(是筛选还是搜索),并给一键清除。 private var noMatchState: some View { VStack(spacing: TX.Space.s) { Text(search.isEmpty ? "该分组下没有主机" : "没有匹配「\(search)」的主机") .font(TX.Font.mono(12.5, .medium)) .foregroundStyle(p.text3) TXButton(title: "清除筛选") { search = "" groupFilter = nil } } .frame(maxWidth: .infinity) .padding(.vertical, TX.Space.xl) } // MARK: - 页头 private var header: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("terminalX") .font(TX.Font.mono(34, .medium)) .tracking(-0.85) .foregroundStyle(p.text) Text(subtitle) .font(TX.Font.mono(12)) .foregroundStyle(p.textWeak) } Spacer(minLength: TX.Space.s) // 「新建连接」按钮已删(用户裁决减法):添加主机走网格尾部的虚线卡; // 搜索框下移到「全部主机」标题行。页头右侧只剩设置。 TXIconButton(icon: "gearshape", label: "设置") { showSettings = true } } .padding(.horizontal, gutter) .padding(.top, TX.Space.l) .padding(.bottom, TX.Space.m) } /// 屏幕左右留白:设计基准(iPad 横屏)40pt;窄窗口收到 20pt,否则内容区被吃掉一大截。 private var gutter: CGFloat { hSize == .compact ? TX.Space.l : TX.Space.screenGutter } private var subtitle: String { var parts: [String] = [] if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") } parts.append("\(hostStore.hosts.count) 台主机") if let tailnet = tailscaleStore.connections.first { parts.append("Tailscale \(tailnet.name)") } return parts.joined(separator: " · ") } private var chips: some View { HStack(spacing: TX.Space.xs) { TXChip(text: "全部", isSelected: groupFilter == nil) { groupFilter = nil } ForEach(groups, id: \.self) { g in TXChip(text: g, isSelected: groupFilter == g) { groupFilter = g } } } } // MARK: - 活动会话卡 /// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片, /// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。 private var liveSessions: [TerminalSession] { let connecting = Set(manager.connectingSessions.map(\.id)) return manager.activeSessions.filter { ($0.isLive || $0.isMinimized) && !connecting.contains($0.id) } } /// 会话卡:**一排固定 3 格**,不足 3 条从左往右排(不拉伸、不居中),每格 **4:3**。 /// /// 固定 3 列的好处:卡片尺寸与位置不随会话数跳动 —— 连上第二台主机时第一张卡不该改变大小。 /// 超过 3 条自动换行到下一排。 private var sessionCards: some View { LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 3), spacing: TX.Space.m ) { ForEach(liveSessions) { s in SessionCard( session: s, shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1, isFocused: manager.focusedID == s.id, onOpen: { // activate:聚焦 + 已断开的自动重连(与侧边栏行同语义)。 manager.activate(s) router.enter(s.id) }, onClose: { withAnimation(TX.Motion.standard) { closing = s } } ) .aspectRatio(4.0 / 3.0, contentMode: .fit) } } } // MARK: - 全部主机(4 列,按内容高度) private var hostGrid: some View { // 设计基准 4 列(iPad 横屏);窄窗口 2 列,否则每张卡窄到主机名都放不下。 LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: hSize == .compact ? 2 : 4), spacing: TX.Space.m) { ForEach(filteredHosts) { host in HostTile(host: host, credName: credName(for: host), hasTsnet: host.tailscaleID != nil, liveSession: manager.sessions.first { $0.host?.id == host.id }) { // 只发起连接:探测 tmux → 弹会话选择器 → 选完由 manager.enterRequest 进终端。 manager.open(host: host) } .contextMenu { Button { editing = host; showEditor = true } label: { Label("编辑", systemImage: "pencil") } Button(role: .destructive) { hostStore.remove(host) } label: { Label("删除", systemImage: "trash") } } } addHostTile } } private var addHostTile: some View { Button { editing = nil showEditor = true } label: { // 高度骨架 = HostTile 的内部结构原样复刻(图标行 30pt + 徽章行),只是全部隐形 // —— 与主机卡**天然等高**,不靠魔法数(曾用 minHeight 96,比主机卡高一截)。 VStack(alignment: .leading, spacing: TX.Space.s) { Color.clear.frame(width: 30, height: 30) TXBadge(text: " ").opacity(0) } .padding(TX.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .overlay { HStack(spacing: TX.Space.xs) { Image(systemName: "plus") .font(.system(size: 11, weight: .medium)) Text("添加主机") .font(TX.Font.mono(12, .medium)) } .foregroundStyle(p.textWeak) } .background( RoundedRectangle(cornerRadius: TX.Radius.card) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .foregroundStyle(p.border) ) } .buttonStyle(.txPress) } private func credName(for host: SavedHost) -> String? { if case .credential(let id) = host.auth { return credStore.list().first { $0.id == id }?.name } return nil } } /// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 tmux、等选择、失败)。 /// 独立视图以直接订阅该会话(`SessionManager` 的转发只保证 manager 自身刷新)。 struct ConnectingRow: View { @ObservedObject var session: TerminalSession let onCancel: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var failed: Bool { session.phaseText.hasPrefix("失败") } var body: some View { HStack(spacing: TX.Space.s) { if failed { Image(systemName: "exclamationmark.triangle") .font(.system(size: 13)) .foregroundStyle(p.danger) } else { ProgressView().controlSize(.small).tint(TXAccent.base) } VStack(alignment: .leading, spacing: 2) { Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)") .font(TX.Font.mono(13, .medium)) .foregroundStyle(p.text) Text(detail) .font(TX.Font.mono(11)) .foregroundStyle(failed ? p.danger : p.textWeak) .lineLimit(1) } Spacer(minLength: TX.Space.s) if failed { TXButton(title: "重试") { session.retry() } } TXButton(title: failed ? "移除" : "取消", action: onCancel) } .padding(.horizontal, TX.Space.m) .padding(.vertical, TX.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.card) .strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1) ) .padding(.top, TX.Space.xs) } private var detail: String { if failed { return String(session.phaseText.dropFirst(3)) } if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" } if !session.banner.isEmpty { return session.banner } return session.phaseText } } // MARK: - 活动会话卡 /// 会话缩略卡:卡头(状态 + 主机名 + tmux + 链路)· 终端文本镜像(底对齐)· 卡脚(元信息 + 主动作)。 /// 离线/重连中的卡片压一层遮罩 + 说明(设计要求遮罩是内容的最后一层,故用 overlay)。 struct SessionCard: View { @ObservedObject var session: TerminalSession let shortcutIndex: Int let isFocused: Bool let onOpen: () -> Void /// 卡右上 ✕(弹二次确认,与终端页红点同语义)。 let onClose: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var statusColor: Color { switch session.link { case .direct, .relay: p.online case .reconnecting: p.reconnecting case .connecting: p.text4 case .offline: p.offline } } private var isTroubled: Bool { switch session.link { case .reconnecting, .offline: true default: false } } /// 整张卡是**一个**按钮:卡脚的「回到会话 →」与点卡片是同一个动作,做成嵌套 Button 只会 /// 抢命中区、还各自一套按压反馈。故卡脚那行只作为文字提示(affordance),不再是控件。 var body: some View { Button(action: onOpen) { VStack(spacing: 0) { cardHead Rectangle().fill(p.divider).frame(height: 1) // 离线/重连时缓存内容轻微模糊 + 压遮罩(设计:blur(1.5px) + rgba(24,26,40,.72)), // 说明「内容还在,只是链路断了」。 TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground) .blur(radius: isTroubled ? 1.5 : 0) .overlay { if isTroubled { troubleVeil } } Rectangle().fill(p.divider).frame(height: 1) cardFoot } // **必须**主动填满外面给的高度:`.frame(height:)` 只是给了个 560 的框,内容不声明 // 要撑满就按自身理想高度居中放进去 —— 内容少的卡会比内容多的矮一截、上下各缩 10pt, // 一排卡片的上下边就对不齐了(改成 LazyVGrid 之前是 HStack 顶对齐,只在底边露馅)。 .frame(maxWidth: .infinity, maxHeight: .infinity) .background(p.surfaceDark) .clipShape(RoundedRectangle(cornerRadius: 13)) .overlay( RoundedRectangle(cornerRadius: 13) .strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1) ) .txShadow(isFocused ? TX.Elevation.cardFocused : TX.Elevation.card) .animation(TX.Motion.quick, value: isFocused) .contentShape(Rectangle()) } .buttonStyle(.txPress) // ✕ 悬浮在卡右上(cardHead 已用占位让出这块):整卡是一个大按钮,✕ 不能做 // 嵌套子按钮(抢命中/双重按压反馈),overlay 在外层才各点各的。 .overlay(alignment: .topTrailing) { Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 11, weight: .medium)) .foregroundStyle(p.textWeak) .frame(width: 34, height: 36) .contentShape(Rectangle()) } .buttonStyle(.txPressTight) .accessibilityLabel("关闭会话 \(session.displayName)") } } private var cardHead: some View { HStack(spacing: TX.Space.xs) { TXStatusDot(color: statusColor) Text(session.displayName) .font(TX.Font.mono(13, .semibold)) .foregroundStyle(p.text) .lineLimit(1) if let t = session.tmuxSummary { Text(t.name.isEmpty ? "tmux" : "tmux \(t.name)") .font(TX.Font.mono(11)) .foregroundStyle(p.textWeak) .lineLimit(1) } Spacer(minLength: TX.Space.xs) linkTag // ✕ 的占位(真按钮在卡外层 overlay):留出宽度免得链路标压在 ✕ 底下。 Color.clear.frame(width: 26, height: 1) } .padding(.horizontal, TX.Space.s) .frame(height: 36) } /// 链路标:设计里这格是 RTT 数字,首版无 RTT 管道 → 显示链路类型/重连次数(见 HANDOFF §12.4)。 @ViewBuilder private var linkTag: some View { switch session.link { case .direct: tag("直连", p.online) case .relay: tag("tsnet", p.online) case .connecting: tag("连接中", p.text4) case .reconnecting(let n): tag("重连中 · 第 \(n) 次", p.reconnecting) case .offline: tag("离线", p.offline) } } private func tag(_ s: String, _ c: Color) -> some View { Text(s).font(TX.Font.mono(11, .medium)).foregroundStyle(c).lineLimit(1) } /// 遮罩:转圈 + 两行说明。压在终端镜像之上,说明「屏幕内容已缓存,恢复后原样接回」。 private var troubleVeil: some View { ZStack { Color(hex: 0x181A28).opacity(0.72) VStack(spacing: TX.Space.s) { if case .reconnecting = session.link { ProgressView().controlSize(.regular).tint(p.reconnecting) } Text(veilTitle) .font(TX.Font.mono(12, .medium)) .foregroundStyle(p.reconnecting) Text("屏幕内容已缓存,恢复后原样接回") .font(TX.Font.mono(10.5)) .foregroundStyle(p.text4) } .multilineTextAlignment(.center) .padding(TX.Space.m) } } private var veilTitle: String { switch session.link { case .reconnecting(let n): "\(session.moshEngaged ? "mosh " : "")会话保活中 · 第 \(n) 次重连" default: "已断开" } } private var cardFoot: some View { HStack(spacing: TX.Space.xs) { Text(footMeta) .font(TX.Font.mono(11)) .foregroundStyle(p.textWeak) .lineLimit(1) Spacer(minLength: TX.Space.xs) Text("⌘\(shortcutIndex)") .font(TX.Font.mono(10.5)) .foregroundStyle(p.textFaint) Text(isTroubled ? "立即重试" : "回到会话 →") .font(TX.Font.mono(11.5, .medium)) .foregroundStyle(TXAccent.textBright) } .padding(.horizontal, TX.Space.s) .frame(height: 38) } private var footMeta: String { var parts: [String] = [] if let t = session.tmuxSummary { parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")") if t.panes > 0 { parts.append("\(t.panes) panes") } } else { parts.append("原生窗口") } if session.isMinimized { parts.append("最小化中") } else { parts.append(Self.relative(session.lastActiveAt)) } return parts.joined(separator: " · ") } /// 「上次活跃」相对时间(秒/分/小时/天)。 static func relative(_ date: Date) -> String { let s = Int(Date().timeIntervalSince(date)) if s < 60 { return "\(max(1, s)) 秒前" } if s < 3600 { return "\(s / 60) 分钟前" } if s < 86400 { return "\(s / 3600) 小时前" } return "\(s / 86400) 天前" } } // MARK: - 主机卡 /// 主机卡(4 列网格):图标 + 名称 + os 副行 + 徽章行 + 右上状态点。 struct HostTile: View { let host: SavedHost let credName: String? let hasTsnet: Bool /// 该主机是否已有活动会话(有则右上点亮——只是状态提示;点击**总是新建**一条并行会话, /// 回到旧会话走上方会话卡 / 侧边栏)。 let liveSession: TerminalSession? let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var icon: String { let os = (host.os ?? "").lowercased() if os.contains("macos") { return "desktopcomputer" } if os.contains("raspberry") { return "cpu" } if os.contains("nas") || host.name.lowercased().contains("nas") { return "externaldrive" } return "server.rack" } var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: TX.Space.s) { HStack(alignment: .top, spacing: TX.Space.s) { // 图标染发行版品牌色(Ubuntu 橙 / Debian 红 / macOS 中性银…), // 一眼扫过去就能按 OS 分辨主机;认不出的发行版回中性。 Image(systemName: icon) .font(.system(size: 15, weight: .light)) .foregroundStyle(TXTech.osTint(host.os, p)) .frame(width: 30, height: 30) .background(p.surface, in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 2) { Text(host.name) .font(TX.Font.mono(13, .semibold)) .foregroundStyle(p.text) .lineLimit(1) Text(host.os ?? host.addressLine) .font(TX.Font.mono(10.5)) .foregroundStyle(p.textWeak) .lineLimit(1) } Spacer(minLength: 0) if let s = liveSession { TXStatusDot(color: s.link == .offline ? p.offline : (s.isMinimized ? TXAccent.mid : p.online)) } else { TXStatusDot(color: p.offline.opacity(0.5)) } } HStack(spacing: 5) { // 传输协议(互斥)着语义冷色;tsnet 是招牌能力,用 app 强调色; // 凭据名是用户自己命的名、不是技术标签 → 中性(详见 TXTech)。 let transport = TXTech.transport(host.useMosh, p) TXBadge(text: transport.text, tint: transport.tint) if hasTsnet { TXBadge(text: "tsnet", emphasized: true) } if let credName { TXBadge(text: credName) } } } .padding(TX.Space.s) .frame(maxWidth: .infinity, alignment: .leading) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.card) .strokeBorder(p.border, lineWidth: 1) ) } .buttonStyle(.txPress) } }