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>
This commit is contained in:
@@ -3,18 +3,22 @@ import TXCore
|
||||
|
||||
/// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。
|
||||
///
|
||||
/// 三种连接方式:**接回已有 tmux 会话** · **新建 tmux 会话** · **原生终端**(不经 tmux)。
|
||||
/// 服务器**没装 tmux 时不显示本 sheet** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。
|
||||
/// **项目风格的居中弹框**(用户裁决第三轮:曾是系统 sheet——顶部手柄 + 半屏形态读作"页面";
|
||||
/// 分段控件 Herdr/最近 是占位噪声,连同 Header 一起删)。与 `CloseConfirmDialog` 同一套
|
||||
/// 卡片语言:TXModalScrim + surfaceDark 卡 + borderStrong 描边 + dialog 阴影。
|
||||
///
|
||||
/// 措辞注意:右上不是「跳过」(那读起来像"跳过某个步骤",含义模糊)——它是一种**并列的连接方式**,
|
||||
/// 所以直接叫「原生终端」。分段控件里 `Herdr` / `最近` 暂时置灰(后续支持),位置先占住避免重排。
|
||||
struct SessionPickerSheet: View {
|
||||
/// 三种连接方式并列:**接回已有 tmux 会话**(列表行)· **新建 tmux 会话** · **原生终端**。
|
||||
/// 服务器**没装 tmux 时不显示本弹框** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。
|
||||
/// 点遮罩 = 取消本次连接(弹框语义:点外面就是不想连了)。
|
||||
struct SessionPickerDialog: View {
|
||||
/// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。
|
||||
let host: String
|
||||
let choice: TmuxSessionChoice
|
||||
let onAttach: (String) -> Void
|
||||
let onCreate: (String) -> Void
|
||||
let onNative: () -> Void
|
||||
/// 点遮罩:取消本次连接(关闭正在建立的会话)。
|
||||
let onCancel: () -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@State private var newName = ""
|
||||
@State private var creating = false
|
||||
@@ -22,28 +26,32 @@ struct SessionPickerSheet: View {
|
||||
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
|
||||
TXModalScrim(onDismiss: onCancel) {
|
||||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||||
title
|
||||
if choice.sessions.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
sessionList
|
||||
}
|
||||
newSessionRow
|
||||
nativeRow
|
||||
}
|
||||
newSessionRow
|
||||
Spacer(minLength: 0)
|
||||
.padding(TX.Space.l)
|
||||
.frame(width: 480)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.dialog)
|
||||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
||||
)
|
||||
.txShadow(TX.Elevation.dialog)
|
||||
}
|
||||
.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))
|
||||
.font(TX.Font.mono(15, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Text("接回一个 tmux 会话,或直接用原生终端")
|
||||
.font(TX.Font.mono(12))
|
||||
@@ -51,65 +59,22 @@ struct SessionPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
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(.txPress)
|
||||
.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: - 会话列表
|
||||
// MARK: - 已有会话列表
|
||||
|
||||
private var sessionList: some View {
|
||||
// 会话多时限高滚动(弹框不该长过屏幕);少时按内容高,不留空槽。
|
||||
Group {
|
||||
if choice.sessions.count > 4 {
|
||||
ScrollView { sessionRows }
|
||||
.frame(height: 264)
|
||||
} else {
|
||||
sessionRows
|
||||
}
|
||||
}
|
||||
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
}
|
||||
|
||||
private var sessionRows: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
|
||||
Button { onAttach(s.name) } label: {
|
||||
@@ -122,14 +87,13 @@ struct SessionPickerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
}
|
||||
|
||||
private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Text(s.name)
|
||||
.font(TX.Font.mono(22, .medium))
|
||||
.font(TX.Font.mono(14, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
// tmux 会话本身就是活着的服务端进程 → 一律「活跃」;被别的客户端占用时另标。
|
||||
TXBadge(text: "活跃", emphasized: true)
|
||||
@@ -137,13 +101,16 @@ struct SessionPickerSheet: View {
|
||||
TXBadge(text: "已被接入")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
Text(subtitle(s))
|
||||
.font(TX.Font.mono(13))
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textWeak)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.vertical, TX.Space.s + 2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -157,19 +124,19 @@ struct SessionPickerSheet: View {
|
||||
private var emptyState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("这台主机上还没有 tmux 会话")
|
||||
.font(TX.Font.mono(15, .medium))
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
|
||||
.font(TX.Font.mono(12))
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineSpacing(3)
|
||||
}
|
||||
.padding(TX.Space.m)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
}
|
||||
|
||||
// MARK: - 新建
|
||||
// MARK: - 新建 tmux 会话
|
||||
|
||||
@ViewBuilder private var newSessionRow: some View {
|
||||
if creating {
|
||||
@@ -189,18 +156,46 @@ struct SessionPickerSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Text("新建 tmux 会话…")
|
||||
.font(TX.Font.mono(15, .medium))
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(TXAccent.textBright)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: 56)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
.frame(height: 44)
|
||||
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 原生终端(并列的第三种连接方式,不是"跳过")
|
||||
|
||||
private var nativeRow: some View {
|
||||
Button(action: onNative) {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(p.text3)
|
||||
Text("原生终端")
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Text("不经过 tmux")
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textWeak)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: 44)
|
||||
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.accessibilityLabel("直接连接原生终端,不经过 tmux")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user