业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
352 lines
14 KiB
Swift
352 lines
14 KiB
Swift
import SwiftUI
|
||
|
||
// 侧边栏两态(收起 56pt 轨道 ⇄ 固定展开 288pt)的**唯一**视图。
|
||
//
|
||
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
|
||
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
|
||
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
|
||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / window 徽标 /
|
||
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
|
||
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
|
||
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
|
||
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
|
||
//
|
||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/window 行)在
|
||
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
|
||
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
|
||
// `allowsHitTesting(expanded)`。
|
||
|
||
/// 整行按钮的命中形状:只有左起 `width` 宽度可点(随开合动画走)。
|
||
private struct LeadingHitShape: Shape {
|
||
var width: CGFloat
|
||
var animatableData: CGFloat {
|
||
get { width }
|
||
set { width = newValue }
|
||
}
|
||
|
||
func path(in rect: CGRect) -> Path {
|
||
Path(CGRect(x: 0, y: 0, width: width, height: rect.height))
|
||
}
|
||
}
|
||
|
||
struct TerminalSidebar: View {
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||
let railWidth: CGFloat
|
||
/// home indicator 安全区(沉浸页铺满物理屏,由底栏自己让位)。
|
||
let safeBottom: CGFloat
|
||
let onSettings: () -> Void
|
||
@Binding var search: String
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var expanded: Bool { router.sidebar.isExpanded }
|
||
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
|
||
|
||
var body: some View {
|
||
content
|
||
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
|
||
.background(p.surfaceDark)
|
||
.frame(width: currentWidth, alignment: .leading)
|
||
.clipped()
|
||
}
|
||
|
||
// 活动会话(主机连接)的切换在侧边栏;tmux window 切换已上移为终端顶部 Tab 条
|
||
// (TmuxWindowTabGroup,用户定稿),侧边栏不再重复列 window。
|
||
private var content: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
header
|
||
searchRow
|
||
.padding(.bottom, TX.Space.s)
|
||
homeRow
|
||
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
|
||
sessionRows
|
||
// 「连接新主机」入口已删(用户裁决第一版减法):新建连接只在首页。
|
||
Spacer(minLength: TX.Space.s)
|
||
tailscaleRow
|
||
footRow
|
||
}
|
||
}
|
||
|
||
// MARK: - 头部
|
||
|
||
/// Logo 位是展开的**主入口**(用户定稿:将来 App Logo 设计与展开按钮一体):
|
||
/// 收起态显展开键(»),展开态还原成 Logo(`›`,不可点)。两者同框同位交叉淡化。
|
||
private var header: some View {
|
||
HStack(spacing: 0) {
|
||
ZStack {
|
||
logoBox { Image(systemName: "chevron.right.2").font(.system(size: 13, weight: .semibold)) }
|
||
.opacity(expanded ? 0 : 1)
|
||
logoBox { Text("›").font(TX.Font.mono(15, .bold)) }
|
||
.opacity(expanded ? 1 : 0)
|
||
}
|
||
.frame(width: railWidth)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { if !expanded { router.toggleSidebar() } }
|
||
.accessibilityLabel(expanded ? "terminalX" : "展开侧边栏")
|
||
.accessibilityAddTraits(expanded ? [] : .isButton)
|
||
HStack(spacing: TX.Space.s) {
|
||
Text("terminalX")
|
||
.font(TX.Font.mono(14, .medium))
|
||
.foregroundStyle(p.text)
|
||
Spacer(minLength: 0)
|
||
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起侧边栏") {
|
||
router.toggleSidebar()
|
||
}
|
||
}
|
||
.padding(.trailing, TX.Space.s)
|
||
.opacity(expanded ? 1 : 0)
|
||
.allowsHitTesting(expanded)
|
||
}
|
||
.padding(.top, 14)
|
||
.padding(.bottom, 10)
|
||
}
|
||
|
||
/// Logo / 展开键共用的 30×30 方框(同框同位,只换内容物)。
|
||
private func logoBox(@ViewBuilder _ inner: () -> some View) -> some View {
|
||
inner()
|
||
.foregroundStyle(TXAccent.text)
|
||
.frame(width: 30, height: 30)
|
||
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
|
||
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
|
||
}
|
||
|
||
/// 搜索:收起 = 裸图标(点击展开去搜),展开 = 输入框。字段的放大镜与图标列中心
|
||
/// 几乎重合(12 + 10 + 6 ≈ railWidth/2),交叉淡化读作"图标长成了输入框"。
|
||
private var searchRow: some View {
|
||
ZStack(alignment: .leading) {
|
||
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
|
||
router.toggleSidebar()
|
||
}
|
||
.frame(width: railWidth)
|
||
.opacity(expanded ? 0 : 1)
|
||
.allowsHitTesting(!expanded)
|
||
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
|
||
.padding(.leading, 12)
|
||
.padding(.trailing, TX.Space.m)
|
||
.opacity(expanded ? 1 : 0)
|
||
.allowsHitTesting(expanded)
|
||
}
|
||
}
|
||
|
||
private var homeRow: some View {
|
||
Button { router.goHome() } label: {
|
||
HStack(spacing: 0) {
|
||
Image(systemName: "house")
|
||
.font(.system(size: 17))
|
||
.foregroundStyle(p.text3)
|
||
.frame(width: railWidth)
|
||
HStack {
|
||
Text("首页")
|
||
.font(TX.Font.mono(13, .medium))
|
||
.foregroundStyle(p.text)
|
||
Spacer(minLength: 0)
|
||
Text("⌘⇧H")
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textFaint)
|
||
}
|
||
.padding(.trailing, TX.Space.m)
|
||
.opacity(expanded ? 1 : 0)
|
||
}
|
||
.frame(height: 36)
|
||
.contentShape(LeadingHitShape(width: currentWidth))
|
||
}
|
||
.buttonStyle(.txPress)
|
||
.accessibilityLabel("首页")
|
||
}
|
||
|
||
// MARK: - 分组标题 ⇄ 轨道短分隔线(同一行的两种密度)
|
||
|
||
/// 展开 = 分组小标题;收起 = 图标列里 24pt 短分隔线。语义同为"分组",交叉淡化。
|
||
private func sectionRow(label: String, trailing: String? = nil) -> some View {
|
||
ZStack(alignment: .leading) {
|
||
shortDivider
|
||
.frame(width: railWidth)
|
||
.opacity(expanded ? 0 : 1)
|
||
TXSectionLabel(text: label, trailing: trailing)
|
||
.padding(.horizontal, TX.Space.m)
|
||
.opacity(expanded ? 1 : 0)
|
||
}
|
||
.frame(height: 26)
|
||
}
|
||
|
||
private var shortDivider: some View {
|
||
Rectangle()
|
||
.fill(p.divider)
|
||
.frame(width: 24, height: 1)
|
||
}
|
||
|
||
// MARK: - 会话
|
||
|
||
@ViewBuilder private var sessionRows: some View {
|
||
if manager.sessions.isEmpty {
|
||
Text("没有在跑的会话")
|
||
.font(TX.Font.mono(11.5))
|
||
.foregroundStyle(p.textWeak)
|
||
.padding(.leading, railWidth)
|
||
.frame(height: 32, alignment: .leading)
|
||
.opacity(expanded ? 1 : 0)
|
||
}
|
||
// **创建顺序**而非最近活跃排序(用户裁决):点击切换会更新 lastActiveAt,
|
||
// 按活跃排序的话每次切换整列都重排 —— 行跳来跳去像抖动。首页卡片仍按活跃排。
|
||
ForEach(manager.sessions) { s in
|
||
SidebarSessionRow(session: s,
|
||
isSelected: router.route.sessionID == s.id,
|
||
expanded: expanded, railWidth: railWidth,
|
||
hitWidth: currentWidth) {
|
||
// activate:聚焦 + 已断开的自动重连(点选会话 = 想用它,不是想看「重试」按钮)。
|
||
manager.activate(s)
|
||
router.enter(s.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮)
|
||
|
||
/// tailnet 状态:点在图标列(两态可见),文字是明细。
|
||
private var tailscaleRow: some View {
|
||
HStack(spacing: 0) {
|
||
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online,
|
||
punchOut: p.surfaceDark)
|
||
.frame(width: railWidth)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
|
||
.font(TX.Font.mono(11.5, .medium))
|
||
.foregroundStyle(p.text2)
|
||
if let c = tailscaleStore.connections.first {
|
||
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textWeak)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(.trailing, TX.Space.m)
|
||
.opacity(expanded ? 1 : 0)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: 40)
|
||
}
|
||
|
||
/// 底部带:分割线与舞台提示条的分割线同一条 y(都在 footBand 顶),齿轮居中在带内。
|
||
private var footRow: some View {
|
||
VStack(spacing: 0) {
|
||
Rectangle().fill(p.divider).frame(height: 1)
|
||
Button(action: onSettings) {
|
||
HStack(spacing: 0) {
|
||
Image(systemName: "gearshape")
|
||
.font(.system(size: 17))
|
||
.foregroundStyle(p.text3)
|
||
.frame(width: railWidth)
|
||
Text("设置")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text4)
|
||
.opacity(expanded ? 1 : 0)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(height: TX.Layout.footBand(safeBottom))
|
||
.contentShape(LeadingHitShape(width: currentWidth))
|
||
}
|
||
.buttonStyle(.txPress)
|
||
.accessibilityLabel("设置")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 轨道与面板共用的状态色映射。
|
||
@MainActor
|
||
enum RailSidebarStyle {
|
||
static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color {
|
||
switch s.link {
|
||
case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online
|
||
case .reconnecting: p.reconnecting
|
||
case .connecting: p.text4
|
||
case .offline: p.offline
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 行
|
||
// (window 行已随「window 切换上移为顶部 Tab」移除,见 TerminalChrome.TmuxWindowTabGroup。)
|
||
|
||
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
|
||
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
|
||
struct SidebarSessionRow: View {
|
||
@ObservedObject var session: TerminalSession
|
||
let isSelected: Bool
|
||
let expanded: Bool
|
||
let railWidth: CGFloat
|
||
let hitWidth: CGFloat
|
||
let onTap: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
HStack(spacing: 0) {
|
||
TXInitialsAvatar(initials: session.initials, size: 30,
|
||
isSelected: isSelected,
|
||
status: RailSidebarStyle.statusColor(session, p),
|
||
punchOut: isSelected && expanded ? TXAccent.selectedBg : p.surfaceDark)
|
||
.frame(width: railWidth)
|
||
HStack(spacing: TX.Space.s) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(title)
|
||
.font(TX.Font.mono(13, .medium))
|
||
.foregroundStyle(p.text)
|
||
.lineLimit(1)
|
||
Text(subtitle)
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textWeak)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer(minLength: TX.Space.xs)
|
||
trailingTag
|
||
}
|
||
.padding(.trailing, TX.Space.m)
|
||
.opacity(expanded ? 1 : 0)
|
||
}
|
||
.frame(height: 44)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.fill(TXAccent.selectedBg)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
|
||
)
|
||
.padding(.horizontal, TX.Space.xs)
|
||
.opacity(isSelected && expanded ? 1 : 0)
|
||
)
|
||
.contentShape(LeadingHitShape(width: hitWidth))
|
||
.animation(TX.Motion.quick, value: isSelected)
|
||
}
|
||
.buttonStyle(.txPress)
|
||
}
|
||
|
||
private var title: String {
|
||
if let t = session.tmuxSummary, !t.name.isEmpty {
|
||
return "\(session.displayName) · \(t.name)"
|
||
}
|
||
return session.displayName
|
||
}
|
||
|
||
private var subtitle: String {
|
||
if session.isMinimized { return "最小化中" }
|
||
guard let t = session.tmuxSummary else { return "原生窗口" }
|
||
let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle
|
||
return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||
}
|
||
|
||
@ViewBuilder private var trailingTag: some View {
|
||
switch session.link {
|
||
case .reconnecting:
|
||
Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting)
|
||
case .offline:
|
||
Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline)
|
||
default:
|
||
EmptyView()
|
||
}
|
||
}
|
||
}
|