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 @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 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 { 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) { TXSectionLabel(text: "全部主机") Spacer() if !groups.isEmpty { chips } } .padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s) if filteredHosts.isEmpty { noMatchState } else { hostGrid } } .padding(.horizontal, TX.Space.screenGutter) .padding(.bottom, TX.Space.xxl) } } } .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) } } // 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() HStack(spacing: TX.Space.s) { TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K") .frame(width: 220) TXButton(title: "新建连接", emphasized: true) { editing = nil showEditor = true } TXIconButton(icon: "gearshape", label: "设置") { showSettings = true } } } .padding(.horizontal, TX.Space.screenGutter) .padding(.top, TX.Space.l) .padding(.bottom, TX.Space.m) } 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 } } } } /// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 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: - 活动会话卡(3 列等宽) /// 已建立的会话。**排除正在连接的**(它们走 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) } } private var sessionCards: some View { HStack(alignment: .top, 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 ) { manager.focus(s) router.enter(s.id) } } } .frame(height: 560) } // MARK: - 全部主机(4 列,按内容高度) private var hostGrid: some View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 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: { VStack { Text("添加主机") .font(TX.Font.mono(12, .medium)) .foregroundStyle(p.textWeak) } .frame(maxWidth: .infinity, minHeight: 96) .background( RoundedRectangle(cornerRadius: TX.Radius.card) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .foregroundStyle(p.border) ) } .buttonStyle(.plain) } 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 @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 } } var body: some View { 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 } .background(p.surfaceDark) .clipShape(RoundedRectangle(cornerRadius: 13)) .overlay( RoundedRectangle(cornerRadius: 13) .strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1) ) .shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6) .contentShape(Rectangle()) .onTapGesture(perform: onOpen) } 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 } .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("direct", 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) Button(action: onOpen) { Text(isTroubled ? "立即重试" : "回到会话 →") .font(TX.Font.mono(11.5, .medium)) .foregroundStyle(TXAccent.textBright) } .buttonStyle(.plain) } .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) { Image(systemName: icon) .font(.system(size: 15, weight: .light)) .foregroundStyle(p.text4) .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) { if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") } if hasTsnet { TXBadge(text: "tsnet") } if let credName { TXBadge(text: credName, emphasized: true) } } } .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(.plain) } }