Files
terminalX/apps/TerminalX/iOS/TerminalChrome.swift
kid 8da725114f feat: 顶带 Shimo 化重设计 + 第一版功能减法
- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标
- 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽,
  window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize)
- 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占
- 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范
- CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑

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

517 lines
23 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 稿 `2c` App mosh ** App
/// ** tmux****
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// tmux
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
///
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
}
/// ****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
}
}