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:
kid
2026-07-26 17:49:58 +08:00
parent 8da725114f
commit 4b5292107b
14 changed files with 378 additions and 470 deletions

View File

@@ -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")
}
}