Files
terminalX/apps/TerminalX/iOS/HomeView.swift
kid 8da725114f feat: 顶带 Shimo 化重设计 + 第一版功能减法
- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标
- 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽,
  window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize)
- 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占
- 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范
- CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:52:19 +08:00

621 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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)
}
}
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) {
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, 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)
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索主机")
// 220 / iPhone
//
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
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
) {
manager.focus(s)
router.enter(s.id)
}
.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: {
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(.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
@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)
}
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("直连", 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)
}
}