按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
633 lines
25 KiB
Swift
633 lines
25 KiB
Swift
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)
|
||
}
|
||
}
|