Files
terminalX/apps/TerminalX/iOS/RailSidebar.swift
kid 5d1abdc7ac feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法
  偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测
  格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏
  格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证)
- 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 +
  TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容
- 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部
  tab,侧边栏改列活动会话;底部动作条合并状态行
- Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需,
  缺失时 socket 静默超时)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:11:20 +08:00

377 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 onNewHost: () -> Void
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
newHostRow
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.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 newHostRow: some View {
Button(action: onNewHost) {
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(.txPress)
.accessibilityLabel("连接新主机")
}
// 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()
}
}
}