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:
@@ -1,158 +1,380 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `1b 终端-沉浸轨道` 的左侧 56pt 轨道 + `3a 侧边栏-遮罩态/Pin 态` 的 288pt 面板。
|
||||
// 侧边栏两态(收起 56pt 轨道 ⇄ 固定展开 288pt)的**唯一**视图。
|
||||
//
|
||||
// **开合语义(唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反——
|
||||
// 折叠态在轨道底部(»展开),展开态在标题右侧(«收起)。Pin 是独立按钮,只在展开态出现。
|
||||
// 三态元素不增不减,只是展示密度不同。
|
||||
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
|
||||
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
|
||||
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 /
|
||||
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
|
||||
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
|
||||
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
|
||||
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
|
||||
//
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在
|
||||
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
|
||||
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
|
||||
// `allowsHitTesting(expanded)`。
|
||||
|
||||
/// 折叠轨道(56pt,竖屏 48pt)。元素中心线统一在轨道正中。
|
||||
struct RailView: View {
|
||||
/// 整行按钮的命中形状:只有左起 `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
|
||||
/// 当前会话(TMUX 段列它的 window)。
|
||||
/// 当前会话(tmux 段列它的 window)。
|
||||
@ObservedObject var session: TerminalSession
|
||||
@ObservedObject var tailscaleStore: TailscaleStore
|
||||
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||||
let railWidth: CGFloat
|
||||
/// home indicator 安全区(沉浸页铺满物理屏,由底栏自己让位)。
|
||||
let safeBottom: CGFloat
|
||||
let onNewHost: () -> Void
|
||||
let onSettings: () -> Void
|
||||
@Binding var search: String
|
||||
@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 }
|
||||
private var expanded: Bool { router.sidebar.isExpanded }
|
||||
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
|
||||
|
||||
// 纵向节奏取自设计稿 `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 与展开键有框。
|
||||
content
|
||||
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
|
||||
.background(p.surfaceDark)
|
||||
.frame(width: currentWidth, alignment: .leading)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
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
|
||||
newHostRow
|
||||
tmuxDivider
|
||||
tmuxLabelRow
|
||||
tmuxRows
|
||||
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()
|
||||
}
|
||||
.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: 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)
|
||||
}
|
||||
.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 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("首页")
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
// MARK: - 会话
|
||||
|
||||
@ViewBuilder private var sessionRows: some View {
|
||||
if manager.activeSessions.isEmpty {
|
||||
Text("没有在跑的会话")
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.padding(.leading, railWidth)
|
||||
.frame(height: 32, alignment: .leading)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
ForEach(manager.activeSessions) { s in
|
||||
SidebarSessionRow(session: s,
|
||||
isSelected: router.route.sessionID == s.id,
|
||||
expanded: expanded, railWidth: railWidth,
|
||||
hitWidth: currentWidth) {
|
||||
manager.focus(s)
|
||||
router.enter(s.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 虚线 + 号:在当前主机之外新开会话。
|
||||
private var newHostButton: some View {
|
||||
/// 虚线 + 号:在当前主机之外新开会话(两态都可点)。
|
||||
private var newHostRow: 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)
|
||||
)
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||
.foregroundStyle(p.border)
|
||||
)
|
||||
.frame(width: railWidth)
|
||||
Text("连接新主机…")
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text4)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 40)
|
||||
.contentShape(LeadingHitShape(width: currentWidth))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 9)
|
||||
.buttonStyle(.txPress)
|
||||
.accessibilityLabel("连接新主机")
|
||||
}
|
||||
|
||||
/// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。
|
||||
private var tmuxSection: some View {
|
||||
VStack(spacing: 7) {
|
||||
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
|
||||
// MARK: - tmux 段
|
||||
|
||||
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
|
||||
private var tmuxDivider: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
shortDivider
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.padding(.vertical, TX.Space.s)
|
||||
}
|
||||
|
||||
/// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写);
|
||||
/// 展开 = 完整分组标题(tmux · 主机 · 会话名)。
|
||||
private var tmuxLabelRow: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
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)
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
TXSectionLabel(text: tmuxHeader)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 22)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder private var tmuxRows: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
ForEach(tmux.windows) { win in
|
||||
SidebarWindowRow(window: win,
|
||||
isSelected: win.id == tmux.activeWindowID,
|
||||
expanded: expanded, railWidth: railWidth,
|
||||
hitWidth: currentWidth) {
|
||||
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
|
||||
WindowRowBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
|
||||
subtitle: i == 0 && summary.panes > 0
|
||||
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
|
||||
isSelected: i == 0,
|
||||
expanded: expanded, railWidth: railWidth)
|
||||
}
|
||||
newWindowRow {}
|
||||
} else {
|
||||
Text("无 tmux · 客户端窗口")
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.padding(.leading, railWidth)
|
||||
.frame(height: 32, alignment: .leading)
|
||||
.opacity(expanded ? 1 : 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)
|
||||
)
|
||||
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
|
||||
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: railWidth, height: 1)
|
||||
Text("新建 window")
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text4)
|
||||
Spacer(minLength: 0)
|
||||
Text("⌘T")
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
.padding(.trailing, TX.Space.m)
|
||||
}
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
.allowsHitTesting(expanded)
|
||||
}
|
||||
|
||||
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
|
||||
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)"
|
||||
}
|
||||
|
||||
// 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("设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轨道与面板共用的状态色映射。
|
||||
@@ -168,283 +390,60 @@ enum RailSidebarStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 288pt 侧边栏面板
|
||||
// MARK: - 行
|
||||
|
||||
/// 展开态面板(遮罩 / 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 + 右侧链路/重连标。
|
||||
/// 会话行:图标列 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: TX.Space.s) {
|
||||
TXInitialsAvatar(initials: session.initials, size: 26,
|
||||
HStack(spacing: 0) {
|
||||
TXInitialsAvatar(initials: session.initials, size: 30,
|
||||
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)
|
||||
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
|
||||
}
|
||||
Spacer(minLength: TX.Space.xs)
|
||||
trailingTag
|
||||
.padding(.trailing, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 48)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||
// inset 描边而非 border:选中时内容不位移 1pt。
|
||||
.overlay(
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
.fill(TXAccent.selectedBg)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.opacity(isSelected && expanded ? 1 : 0)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.contentShape(LeadingHitShape(width: hitWidth))
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
@@ -473,13 +472,14 @@ struct SidebarSessionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧边栏 window 行:26×22pt 编号徽标 + title + cwd/panes。
|
||||
/// window 行:图标列 26×22 编号徽标(两态同位,选中态徽标自己高亮)+ 明细(title + cwd/panes)。
|
||||
struct SidebarWindowRow: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
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 }
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = []
|
||||
@@ -491,19 +491,44 @@ struct SidebarWindowRow: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
|
||||
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
|
||||
.contentShape(LeadingHitShape(width: hitWidth))
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
/// window 行的视觉本体(SidebarWindowRow 与占位行共用)。
|
||||
struct WindowRowBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let isSelected: Bool
|
||||
let expanded: Bool
|
||||
let railWidth: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
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))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.frame(width: railWidth)
|
||||
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)
|
||||
Text(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)
|
||||
@@ -514,16 +539,20 @@ struct SidebarWindowRow: View {
|
||||
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)
|
||||
.padding(.trailing, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.fill(TXAccent.selectedBg.opacity(0.75))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.opacity(isSelected && expanded ? 1 : 0)
|
||||
)
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user