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() } } }