按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
8.0 KiB
Swift
207 lines
8.0 KiB
Swift
import SwiftUI
|
||
import TXCore
|
||
|
||
/// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。
|
||
///
|
||
/// 三种连接方式:**接回已有 tmux 会话** · **新建 tmux 会话** · **原生终端**(不经 tmux)。
|
||
/// 服务器**没装 tmux 时不显示本 sheet** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。
|
||
///
|
||
/// 措辞注意:右上不是「跳过」(那读起来像"跳过某个步骤",含义模糊)——它是一种**并列的连接方式**,
|
||
/// 所以直接叫「原生终端」。分段控件里 `Herdr` / `最近` 暂时置灰(后续支持),位置先占住避免重排。
|
||
struct SessionPickerSheet: View {
|
||
/// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。
|
||
let host: String
|
||
let choice: TmuxSessionChoice
|
||
let onAttach: (String) -> Void
|
||
let onCreate: (String) -> Void
|
||
let onNative: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@State private var newName = ""
|
||
@State private var creating = false
|
||
@FocusState private var nameFocused: Bool
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||
grabber
|
||
title
|
||
header
|
||
if choice.sessions.isEmpty {
|
||
emptyState
|
||
} else {
|
||
sessionList
|
||
}
|
||
newSessionRow
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, TX.Space.l)
|
||
.padding(.bottom, TX.Space.l)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(p.bg.ignoresSafeArea())
|
||
}
|
||
|
||
private var title: some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("连接到 \(host)")
|
||
.font(TX.Font.mono(17, .medium))
|
||
.foregroundStyle(p.text)
|
||
Text("接回一个 tmux 会话,或直接用原生终端")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.textWeak)
|
||
}
|
||
}
|
||
|
||
private var grabber: some View {
|
||
RoundedRectangle(cornerRadius: 2.5)
|
||
.fill(p.borderStrong)
|
||
.frame(width: 44, height: 5)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, TX.Space.s)
|
||
}
|
||
|
||
// MARK: - 分段控件 + 跳过
|
||
|
||
private var header: some View {
|
||
HStack(spacing: TX.Space.s) {
|
||
HStack(spacing: 2) {
|
||
segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true)
|
||
segment(icon: "shippingbox", title: "Herdr", enabled: false)
|
||
segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false)
|
||
}
|
||
.padding(4)
|
||
.background(p.surface, in: Capsule())
|
||
Spacer(minLength: TX.Space.s)
|
||
Button(action: onNative) {
|
||
HStack(spacing: TX.Space.xs) {
|
||
Image(systemName: "terminal")
|
||
.font(.system(size: 13, weight: .medium))
|
||
Text("原生终端")
|
||
.font(TX.Font.mono(15, .medium))
|
||
}
|
||
.foregroundStyle(p.text)
|
||
.padding(.horizontal, TX.Space.m)
|
||
.frame(height: 44)
|
||
.background(p.surface, in: Capsule())
|
||
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("直接连接原生终端,不经过 tmux")
|
||
}
|
||
}
|
||
|
||
private func segment(icon: String, title: String, enabled: Bool) -> some View {
|
||
HStack(spacing: TX.Space.xs) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 13, weight: .medium))
|
||
Text(title)
|
||
.font(TX.Font.mono(15, .medium))
|
||
}
|
||
.foregroundStyle(enabled ? p.text : p.textWeak.opacity(0.7))
|
||
.padding(.horizontal, TX.Space.m)
|
||
.frame(height: 36)
|
||
.background(enabled ? p.bgDeep : .clear, in: Capsule())
|
||
.overlay {
|
||
if enabled {
|
||
Capsule().strokeBorder(p.border, lineWidth: 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 会话列表
|
||
|
||
private var sessionList: some View {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
|
||
Button { onAttach(s.name) } label: {
|
||
sessionRow(s)
|
||
}
|
||
.buttonStyle(.plain)
|
||
if index < choice.sessions.count - 1 {
|
||
Rectangle().fill(p.divider).frame(height: 1)
|
||
.padding(.horizontal, TX.Space.m)
|
||
}
|
||
}
|
||
}
|
||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||
}
|
||
|
||
private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: TX.Space.s) {
|
||
Text(s.name)
|
||
.font(TX.Font.mono(22, .medium))
|
||
.foregroundStyle(p.text)
|
||
// tmux 会话本身就是活着的服务端进程 → 一律「活跃」;被别的客户端占用时另标。
|
||
TXBadge(text: "活跃", emphasized: true)
|
||
if s.isAttached {
|
||
TXBadge(text: "已被接入")
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
Text(subtitle(s))
|
||
.font(TX.Font.mono(13))
|
||
.foregroundStyle(p.textWeak)
|
||
}
|
||
.padding(.horizontal, TX.Space.m)
|
||
.padding(.vertical, 18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
private func subtitle(_ s: TmuxSessionProbe.Session) -> String {
|
||
var parts = ["\(s.windows) 个窗口"]
|
||
if let t = s.lastActivity { parts.append(SessionCard.relative(t)) }
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("这台主机上还没有 tmux 会话")
|
||
.font(TX.Font.mono(15, .medium))
|
||
.foregroundStyle(p.text)
|
||
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.textWeak)
|
||
.lineSpacing(3)
|
||
}
|
||
.padding(TX.Space.m)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||
}
|
||
|
||
// MARK: - 新建
|
||
|
||
@ViewBuilder private var newSessionRow: some View {
|
||
if creating {
|
||
HStack(spacing: TX.Space.s) {
|
||
TXSearchField(text: $newName, placeholder: choice.defaultNewName)
|
||
.focused($nameFocused)
|
||
TXButton(title: "创建", emphasized: true) {
|
||
let name = newName.trimmingCharacters(in: .whitespaces)
|
||
onCreate(name.isEmpty ? choice.defaultNewName : name)
|
||
}
|
||
TXButton(title: "取消") { creating = false }
|
||
}
|
||
} else {
|
||
Button {
|
||
creating = true
|
||
nameFocused = true
|
||
} label: {
|
||
HStack(spacing: TX.Space.s) {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 13, weight: .medium))
|
||
Text("新建 tmux 会话…")
|
||
.font(TX.Font.mono(15, .medium))
|
||
Spacer(minLength: 0)
|
||
}
|
||
.foregroundStyle(TXAccent.textBright)
|
||
.padding(.horizontal, TX.Space.m)
|
||
.frame(height: 56)
|
||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|