Files
terminalX/apps/TerminalX/iOS/RailSidebar.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多: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>
2026-07-26 17:49:58 +08:00

352 lines
14 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 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()
}
}
}