feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份, 内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画), 图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开 - 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«; 删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸 - 修 GridBox 从不通知 UI:onGridChange→objectWillChange, 标题胶囊格子数随开合实时刷新 触控化走查(上一轮,真机部署验证): - 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮 - TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射) - 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ struct HomeView: View {
|
||||
@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
|
||||
@@ -41,6 +42,31 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -71,29 +97,16 @@ struct HomeView: View {
|
||||
hostGrid
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, TX.Space.screenGutter)
|
||||
.padding(.bottom, TX.Space.xxl)
|
||||
.padding(.horizontal, gutter)
|
||||
// 末行要能完整滚到安全区之上(否则最后一排主机卡永远被 home indicator 压着)。
|
||||
.padding(.bottom, TX.Space.xxl + safeBottom)
|
||||
}
|
||||
// 列表内容从物理底边流出去(系统滚动视图的标准观感),别在安全区上方硬切一刀。
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
// 会话卡/进度行的增减跟着同一条曲线,避免连上/断开时整页跳一下。
|
||||
.animation(TX.Motion.standard, value: manager.sessions.count)
|
||||
}
|
||||
}
|
||||
.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: - 空状态
|
||||
@@ -178,22 +191,30 @@ struct HomeView: View {
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.textWeak)
|
||||
}
|
||||
Spacer()
|
||||
Spacer(minLength: TX.Space.s)
|
||||
HStack(spacing: TX.Space.s) {
|
||||
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
|
||||
.frame(width: 220)
|
||||
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, TX.Space.screenGutter)
|
||||
.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) 个会话在跑") }
|
||||
@@ -213,60 +234,7 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 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 列等宽)
|
||||
// MARK: - 活动会话卡
|
||||
|
||||
/// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片,
|
||||
/// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。
|
||||
@@ -277,8 +245,15 @@ struct ConnectingRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话卡:**一排固定 3 格**,不足 3 条从左往右排(不拉伸、不居中),每格 **4:3**。
|
||||
///
|
||||
/// 固定 3 列的好处:卡片尺寸与位置不随会话数跳动 —— 连上第二台主机时第一张卡不该改变大小。
|
||||
/// 超过 3 条自动换行到下一排。
|
||||
private var sessionCards: some View {
|
||||
HStack(alignment: .top, spacing: TX.Space.m) {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 3),
|
||||
spacing: TX.Space.m
|
||||
) {
|
||||
ForEach(liveSessions) { s in
|
||||
SessionCard(
|
||||
session: s,
|
||||
@@ -288,15 +263,17 @@ struct ConnectingRow: View {
|
||||
manager.focus(s)
|
||||
router.enter(s.id)
|
||||
}
|
||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
.frame(height: 560)
|
||||
}
|
||||
|
||||
// MARK: - 全部主机(4 列,按内容高度)
|
||||
|
||||
private var hostGrid: some View {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4),
|
||||
// 设计基准 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,
|
||||
@@ -336,7 +313,7 @@ struct ConnectingRow: View {
|
||||
.foregroundStyle(p.border)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
|
||||
private func credName(for host: SavedHost) -> String? {
|
||||
@@ -428,27 +405,36 @@ struct SessionCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 整张卡是**一个**按钮:卡脚的「回到会话 →」与点卡片是同一个动作,做成嵌套 Button 只会
|
||||
/// 抢命中区、还各自一套按压反馈。故卡脚那行只作为文字提示(affordance),不再是控件。
|
||||
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
|
||||
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())
|
||||
}
|
||||
.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)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
|
||||
private var cardHead: some View {
|
||||
@@ -528,12 +514,9 @@ struct SessionCard: View {
|
||||
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)
|
||||
Text(isTroubled ? "立即重试" : "回到会话 →")
|
||||
.font(TX.Font.mono(11.5, .medium))
|
||||
.foregroundStyle(TXAccent.textBright)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 38)
|
||||
@@ -590,9 +573,11 @@ struct HostTile: 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(p.text4)
|
||||
.foregroundStyle(TXTech.osTint(host.os, p))
|
||||
.frame(width: 30, height: 30)
|
||||
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -614,9 +599,12 @@ struct HostTile: View {
|
||||
}
|
||||
}
|
||||
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) }
|
||||
// 传输协议(互斥)着语义冷色;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)
|
||||
@@ -627,6 +615,6 @@ struct HostTile: View {
|
||||
.strokeBorder(p.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user