import SwiftUI // 设计稿 `1b 终端-沉浸轨道` 的左侧 56pt 轨道 + `3a 侧边栏-遮罩态/Pin 态` 的 288pt 面板。 // // **开合语义(唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反—— // 折叠态在轨道底部(»展开),展开态在标题右侧(«收起)。Pin 是独立按钮,只在展开态出现。 // 三态元素不增不减,只是展示密度不同。 /// 折叠轨道(56pt,竖屏 48pt)。元素中心线统一在轨道正中。 struct RailView: View { @ObservedObject var manager: SessionManager @ObservedObject var router: AppRouter /// 当前会话(TMUX 段列它的 window)。 @ObservedObject var session: TerminalSession let onNewHost: () -> Void let onSettings: () -> Void @EnvironmentObject var theme: TXThemeManager @Environment(\.horizontalSizeClass) private var hSize private var p: TXPalette { theme.palette } private var width: CGFloat { hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth } private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 } // 纵向节奏取自设计稿 `1b` 的实测坐标(1366×1024 画板扫描): // logo 28..62 · 搜索 75..109 · 首页 118..152 · 分隔 160 · 头像 174/220/266(间距 8) // · 虚线+ 313..351 · 分隔 370 · TMUX 标签 382 · window 数字 408 起(间距 7) // · 底部自下而上:齿轮 985..1019 · 展开键 932..967 · 状态点 914。 var body: some View { VStack(spacing: 0) { logo .padding(.top, 28) .padding(.bottom, 13) // 搜索 / 首页 / 设置在设计稿里是**裸图标**(无底无框),只有 logo 与展开键有框。 TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") { router.toggleSidebar() } .padding(.bottom, 9) TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() } .padding(.bottom, 8) divider .padding(.bottom, 13) hostAvatars newHostButton divider .padding(.top, 9) .padding(.bottom, 12) tmuxSection Spacer(minLength: TX.Space.s) TXStatusDot(color: linkColor, punchOut: p.surfaceDark) .padding(.bottom, 11) // 折叠态的开合键:方向朝右(展开)。设计稿里这一枚**有框**。 TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") { router.toggleSidebar() } .padding(.bottom, 18) TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings) .padding(.bottom, 5) } .frame(width: width) .background(p.surfaceDark) } /// App logo(`›`)。**不可点** —— 开合是另一枚按钮的职责。 private var logo: some View { Text("›") .font(TX.Font.mono(16, .bold)) .foregroundStyle(TXAccent.text) .frame(width: 34, height: 34) .background(p.surface, in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1)) .accessibilityLabel("terminalX") } private var divider: some View { Rectangle() .fill(p.divider) .frame(width: 24, height: 1) } private var hostAvatars: some View { VStack(spacing: 8) { ForEach(manager.activeSessions) { s in Button { manager.focus(s) router.enter(s.id) } label: { TXInitialsAvatar(initials: s.initials, size: avatarSize, isSelected: router.route.sessionID == s.id, status: RailSidebarStyle.statusColor(s, p), punchOut: p.surfaceDark) } .buttonStyle(.plain) } } } /// 虚线 + 号:在当前主机之外新开会话。 private var newHostButton: some View { Button(action: onNewHost) { Image(systemName: "plus") .font(.system(size: 13, weight: .medium)) .foregroundStyle(p.textWeak) .frame(width: avatarSize, height: avatarSize) .background( RoundedRectangle(cornerRadius: 11) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3])) .foregroundStyle(p.border) ) } .buttonStyle(.plain) .padding(.top, 9) } /// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。 private var tmuxSection: some View { VStack(spacing: 7) { Text(session.tmuxSummary == nil ? "窗口" : "TMUX") .font(TX.Font.mono(9, .semibold)) .tracking(0.9) // 设计稿:轨道的 TMUX 段标签用 accent-500(弱化强调),不是中性灰。 .foregroundStyle(TXAccent.mid) .padding(.bottom, 9) if let tmux = session.tmuxController { ForEach(tmux.windows) { win in Button { tmux.selectWindow(win.id) } label: { windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID) } .buttonStyle(.plain) } } else { // 还没 attach(或假会话)时按概览里的 window 数占位,保持轨道高度稳定。 ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in windowNumber("\(i)", isActive: i == 0) } } } } /// window 数字格(34×30,选中态填控件底 + 中等描边)。 private func windowNumber(_ text: String, isActive: Bool) -> some View { Text(text) .font(TX.Font.mono(12, isActive ? .semibold : .regular)) .foregroundStyle(isActive ? p.text : p.text4) .frame(width: 34, height: 30) .background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1) ) } private var linkColor: Color { RailSidebarStyle.statusColor(session, p) } } /// 轨道与面板共用的状态色映射。 @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: - 288pt 侧边栏面板 /// 展开态面板(遮罩 / Pin 共用同一套内容)。 /// /// 行规格:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap /// → 两者标题左缘对齐。选中态用 **inset 描边**(`strokeBorder`)而非 border,避免内容位移 1pt。 struct SidebarPanel: View { @ObservedObject var manager: SessionManager @ObservedObject var router: AppRouter @ObservedObject var session: TerminalSession @ObservedObject var tailscaleStore: TailscaleStore let onNewHost: () -> Void let onSettings: () -> Void @Binding var search: String @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { VStack(alignment: .leading, spacing: 0) { header TXSearchField(text: $search, placeholder: "搜索主机 · 会话") .padding(.horizontal, TX.Space.m) .padding(.bottom, TX.Space.s) homeRow sessionsSection // 设计:1pt **渐隐**分隔(两端淡出,不是通栏实线)。 LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)], startPoint: .leading, endPoint: .trailing) .frame(height: 1) .padding(.horizontal, TX.Space.m) .padding(.vertical, TX.Space.s) tmuxSection Spacer(minLength: 0) tailscaleFoot } .frame(width: TX.Layout.sidebarWidth) .background(p.surfaceDark) } private var header: some View { HStack(spacing: TX.Space.s) { Text("›") .font(TX.Font.mono(15, .bold)) .foregroundStyle(TXAccent.text) .frame(width: 30, height: 30) .background(p.surface, in: RoundedRectangle(cornerRadius: 9)) .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1)) Text("terminalX") .font(TX.Font.mono(14, .medium)) .foregroundStyle(p.text) Spacer() // Pin 只在展开态出现;钉住时高亮。 TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned, label: "钉在左侧") { router.togglePin() } // 展开态的开合键:方向朝左(收起)。 TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() } } .padding(.horizontal, TX.Space.m) .padding(.top, 11) .padding(.bottom, 6) } private var homeRow: some View { Button { router.goHome() } label: { HStack(spacing: TX.Space.s) { Image(systemName: "house") .font(.system(size: 13)) .foregroundStyle(p.text2) .frame(width: 26) Text("首页") .font(TX.Font.mono(13, .medium)) .foregroundStyle(p.text) Spacer() Text("⌘⇧H") .font(TX.Font.mono(10.5)) .foregroundStyle(p.textFaint) } .padding(.horizontal, TX.Space.m) .frame(height: 36) } .buttonStyle(.plain) } private var sessionsSection: some View { VStack(alignment: .leading, spacing: 2) { TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)") .padding(.horizontal, TX.Space.m) .padding(.top, TX.Space.s) .padding(.bottom, 2) if manager.activeSessions.isEmpty { Text("没有在跑的会话") .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .padding(.leading, 53) .frame(height: 32, alignment: .leading) } ForEach(manager.activeSessions) { s in SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) { manager.focus(s) router.enter(s.id) } } Button(action: onNewHost) { Text("连接新主机…") .font(TX.Font.mono(12)) .foregroundStyle(p.text4) .padding(.leading, 53) // 与会话行标题左缘对齐 .frame(height: 32, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) } } private var tmuxSection: some View { VStack(alignment: .leading, spacing: 2) { TXSectionLabel(text: tmuxHeader) .padding(.horizontal, TX.Space.m) .padding(.bottom, 2) if let tmux = session.tmuxController { ForEach(tmux.windows) { win in SidebarWindowRow(window: win, isSelected: win.id == tmux.activeWindowID) { tmux.selectWindow(win.id) } } newWindowRow { tmux.newWindow() } } else if let summary = session.tmuxSummary { // 已知是 tmux 会话但 controller 还没 attach:按概览占位,避免与分组标题矛盾。 ForEach(0 ..< max(1, summary.windows), id: \.self) { i in placeholderWindowRow( index: i, title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)", detail: i == 0 && summary.panes > 0 ? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "", isSelected: i == 0) } newWindowRow {} } else { Text("无 tmux · 客户端窗口") .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .padding(.horizontal, TX.Space.m) .padding(.vertical, TX.Space.xs) } } } /// 「新建 window ⌘T」行(左缘与 window 行标题对齐 x=53)。 private func newWindowRow(_ action: @escaping () -> Void) -> some View { Button(action: action) { HStack { Text("新建 window") .font(TX.Font.mono(12)) .foregroundStyle(p.text4) Spacer() Text("⌘T") .font(TX.Font.mono(10.5)) .foregroundStyle(p.textFaint) } .padding(.leading, 53) .padding(.trailing, TX.Space.m) .frame(height: 32) } .buttonStyle(.plain) } /// controller 未就绪时的 window 行占位(视觉与 SidebarWindowRow 一致)。 private func placeholderWindowRow(index: Int, title: String, detail: String, isSelected: Bool) -> some View { HStack(spacing: TX.Space.s) { Text("\(index)") .font(TX.Font.mono(11, .semibold)) .foregroundStyle(isSelected ? TXAccent.text : p.text4) .frame(width: 26, height: 22) .background(isSelected ? TXAccent.selectedBg : .clear, in: RoundedRectangle(cornerRadius: 5)) VStack(alignment: .leading, spacing: 1) { Text(title) .font(TX.Font.mono(13, isSelected ? .medium : .regular)) .foregroundStyle(isSelected ? p.text : p.text2) .lineLimit(1) if !detail.isEmpty { Text(detail) .font(TX.Font.mono(10.5)) .foregroundStyle(p.textWeak) } } Spacer(minLength: 0) if isSelected { TXStatusDot(color: p.online, size: 7) } } .padding(.horizontal, TX.Space.s) .frame(height: 44) .background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear, in: RoundedRectangle(cornerRadius: TX.Radius.control)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.control) .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) ) .padding(.horizontal, TX.Space.s) } private var tmuxHeader: String { let host = session.displayName if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" } return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)" } /// 面板脚:tailnet 状态(绿点 + tailnet + 设备 IP)。 /// /// **与舞台底部提示条同高**(都是 `TX.Layout.footHeight`),故两侧的分割线落在同一条 y 上; /// 分割线用 1pt divider(设计稿如此),不加底色块、不加阴影。 private var tailscaleFoot: some View { VStack(spacing: 0) { Rectangle().fill(p.divider).frame(height: 1) HStack(spacing: TX.Space.s) { TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online) 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 { // 设计:tailnet 内主机名 + 本机 tailnet IP(Up 后缓存的 SelfIP)。 Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · ")) .font(TX.Font.mono(10.5)) .foregroundStyle(p.textWeak) .lineLimit(1) } } Spacer(minLength: 0) } .padding(.horizontal, TX.Space.m) .frame(height: TX.Layout.footHeight) } } } /// 侧边栏会话行:26pt 头像 + 名称·window title + 右侧链路/重连标。 struct SidebarSessionRow: View { @ObservedObject var session: TerminalSession let isSelected: Bool let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } var body: some View { Button(action: onTap) { HStack(spacing: TX.Space.s) { TXInitialsAvatar(initials: session.initials, size: 26, isSelected: isSelected, status: RailSidebarStyle.statusColor(session, p), punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark) 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(.horizontal, TX.Space.s) .frame(height: 48) .background(isSelected ? TXAccent.selectedBg : .clear, in: RoundedRectangle(cornerRadius: TX.Radius.control)) // inset 描边而非 border:选中时内容不位移 1pt。 .overlay( RoundedRectangle(cornerRadius: TX.Radius.control) .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) ) .padding(.horizontal, TX.Space.s) } .buttonStyle(.plain) } 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() } } } /// 侧边栏 window 行:26×22pt 编号徽标 + title + cwd/panes。 struct SidebarWindowRow: View { @ObservedObject var window: TmuxWindow let isSelected: Bool let onTap: () -> Void @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var subtitle: String { var parts: [String] = [] if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) } let n = window.panes.count if n > 1 { parts.append("\(n) panes") } return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ") } var body: some View { Button(action: onTap) { HStack(spacing: TX.Space.s) { Text("\(window.index)") .font(TX.Font.mono(11, .semibold)) .foregroundStyle(isSelected ? TXAccent.text : p.text4) .frame(width: 26, height: 22) .background(isSelected ? TXAccent.selectedBg : .clear, in: RoundedRectangle(cornerRadius: 5)) VStack(alignment: .leading, spacing: 1) { Text(window.title) .font(TX.Font.mono(13, isSelected ? .medium : .regular)) .foregroundStyle(isSelected ? p.text : p.text2) .lineLimit(1) // 设计:cwd · n panes(cwd 来自 #{pane_current_path}) Text(subtitle) .font(TX.Font.mono(10.5)) .foregroundStyle(p.textWeak) .lineLimit(1) } Spacer(minLength: 0) if isSelected { TXStatusDot(color: p.online, size: 7) } } .padding(.horizontal, TX.Space.s) .frame(height: 44) .background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear, in: RoundedRectangle(cornerRadius: TX.Radius.control)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.control) .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) ) .padding(.horizontal, TX.Space.s) } .buttonStyle(.plain) } }