Files
terminalX/apps/TerminalX/iOS/TerminalChrome.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

436 lines
19 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
// `1b` ** chrome **tab
// **** bgDeep pane 86pt
//
// Shimo ** + **
//
// [ ] · · · [ tab ] [Mosh/SSH ]
// tab = tmux window window tab
/// + + tmux window tab +
///
/// Shimo / ****macOS +
/// ` · cwd · ` `·`
/// tab = accent Mosh/SSH
/// mosh 线
struct TerminalTopBar: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.m) {
// 沿 macOS
HStack(spacing: TX.Space.s) {
windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose)
// chevron =
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize)
}
titleLine
TmuxWindowTabGroup(session: session)
Spacer(minLength: 0)
}
.frame(height: 40)
// overlay HStack tab ScrollView
// tab chip
.overlay(alignment: .trailing) { transportBadge }
}
/// + ` · cwd · ``·` Shimo
///
private var titleLine: some View {
HStack(spacing: TX.Space.xs) {
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5)
Text(session.displayName)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.text3)
.lineLimit(1)
if !contextLine.isEmpty {
dotSeparator
Text(contextLine)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
dotSeparator
// resize GridBox.onGridChange
// session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
}
private var dotSeparator: some View {
Text("·")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
/// Shimo + mosh/SSH
/// ****
/// `TXTech`mosh SSH
@ViewBuilder private var transportBadge: some View {
if session.isLive || session.moshEngaged {
let t = TXTech.transport(session.moshEngaged, p)
Text(t.text)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(t.tint)
.padding(.horizontal, 12)
.frame(height: 26)
// tint bgDeep badge tab
// chip
.background(t.tint.opacity(0.14), in: Capsule())
.background(p.bgDeep, in: Capsule())
}
}
/// cwd window title tab chip
/// tmux tab 退
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
}
/// `/Users/yz/src/x` `~/src/x`
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
// home
for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) {
let rest = path.dropFirst(prefix.count)
if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] }
return "~"
}
return path
}
private var gridLabel: String {
// tmux controller raw screenGrid RawTerminalView
// raw surface screenGrid
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
return "\(g.cols)×\(g.rows)"
}
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
/// / ****Shimo + hover
/// ==
///
private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Color.black.opacity(0.55))
.frame(width: 26, height: 26)
.background(tint, in: Circle())
.frame(width: 30, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
.accessibilityLabel(label)
}
}
/// tmux window **tab **Shimo +
/// chip
///
/// **** chip accent chip
/// `+` window tab
/// controller attach tmux / fixture
struct TmuxWindowTabGroup: View {
@ObservedObject var session: TerminalSession
var body: some View {
if let tmux = session.tmuxController {
WindowTabGroupRow(controller: tmux)
} else if let summary = session.tmuxSummary {
TabGroupSlot {
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
WindowTabChipBody(
index: "\(i)",
title: i == 0 && !summary.currentTitle.isEmpty
? summary.currentTitle : "window \(i)",
isSelected: i == 0)
}
}
}
}
}
/// tmux tab controller ObservableObject
private struct WindowTabGroupRow: View {
@ObservedObject var controller: TmuxController
var body: some View {
// `+` window tmux
// prefix+c / prefix+&tab ****
TabGroupSlot {
ForEach(controller.windows) { win in
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
controller.selectWindow(win.id)
}
}
}
}
}
/// tab ****chip Shimo
///
private struct TabGroupSlot<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: TX.Space.hairline) { content }
}
.frame(height: 36)
}
}
/// window chiptitle/index window @Published
private struct WindowTabChip: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
}
.buttonStyle(.txPressTight)
.accessibilityLabel("切换到 window \(window.index)")
}
}
/// chip window ` ` = accent
/// Shimo =
private struct WindowTabChipBody: View {
let index: String
let title: String
let isSelected: Bool
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 5) {
Text(index)
.font(TX.Font.mono(10.5, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
Text(title)
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? TXAccent.textBright : p.textWeak)
.lineLimit(1)
}
.padding(.horizontal, 12)
.frame(height: 28)
.background(isSelected ? TXAccent.selectedBg : .clear, in: Capsule())
.contentShape(Rectangle())
.animation(TX.Motion.quick, value: isSelected)
}
}
/// **** / /
///
/// tsnet Up dial mosh tmux attach
///
/// ****
struct TerminalStateOverlay: View {
@ObservedObject var session: TerminalSession
let onRetry: () -> Void
let onBack: () -> Void
///
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
/// tsnet Up / mosh
@State private var waited = 0
private var p: TXPalette { theme.palette }
/// phaseText SessionMachine ``
private var failure: String? {
session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil
}
private var isConnecting: Bool {
!session.isLive && failure == nil && session.phaseText != "已关闭"
}
var body: some View {
if let failure {
state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败",
detail: failure) {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重试", emphasized: true, action: onRetry)
}
}
} else if session.phaseText == "已关闭" {
state(icon: "power", tint: p.textWeak, title: "会话已关闭",
detail: "服务端连接已断开。") {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重新连接", emphasized: true, action: onRetry)
}
}
} else if isConnecting {
state(icon: nil, tint: p.text4, title: session.phaseText,
detail: session.banner.isEmpty ? connectHint : session.banner) {
VStack(spacing: TX.Space.xs) {
if waited >= 3 {
Text("已等待 \(waited)s")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
if waited >= 5 {
// 12s
TXButton(title: "取消连接", action: onCancel)
}
}
}
.task(id: session.phaseText) {
waited = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
waited += 1
}
}
}
}
/// tsnet Up mosh
private var connectHint: String {
switch session.link {
case .relay: "正在经 Tailscale 出口建立连接…"
case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…"
case .reconnecting(let n): "\(n) 次重连中…"
case .offline: "等待连接…"
}
}
private func state<Actions: View>(icon: String?, tint: Color, title: String, detail: String,
@ViewBuilder actions: () -> Actions) -> some View {
VStack(spacing: TX.Space.s) {
if let icon {
Image(systemName: icon)
.font(.system(size: 26, weight: .light))
.foregroundStyle(tint)
} else {
ProgressView().controlSize(.regular).tint(TXAccent.base)
}
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 420)
actions()
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// "" paneArea phaseText
.transition(.opacity)
}
}
// tmux tmux tmux
//
/// ****window tmux
/// /
///
/// **/**`[dot ] | tmux `mosh/SSH
/// **** mosh
///
/// ****/ /tmux
/// tmux ·
///
/// = 34pt + home indicator ****
/// 线 34pt 24pt ""
struct TerminalActionBar: View {
@ObservedObject var session: TerminalSession
let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
statusLine
Spacer(minLength: TX.Space.s)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footBand(safeBottom))
.animation(TX.Motion.standard, value: contextText)
}
// MARK: -
private var statusLine: some View {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(contextText, color: p.text3, dot: false)
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 12)
.padding(.horizontal, TX.Space.xs)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// tsnet / / TXTech tsnet
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "直连"
case .connecting: "连接中"
case .reconnecting(let n): "重连中 · 第 \(n)"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// windows
private var contextText: String {
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
// / tmux
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : session.phaseText
}
}