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

@@ -18,6 +18,8 @@ struct HomeView: View {
@State private var showEditor = false
@State private var editing: SavedHost?
@State private var showSettings = false
/// `CloseConfirmDialog`
@State private var closing: TerminalSession?
private var p: TXPalette { theme.palette }
@@ -64,6 +66,30 @@ struct HomeView: View {
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
//
.overlay {
if let s = closing {
CloseConfirmDialog(
session: s,
onDetach: { closeFromHome(s, kill: false) },
onKill: { closeFromHome(s, kill: true) },
onCancel: { withAnimation(TX.Motion.standard) { closing = nil } })
.transition(TX.Motion.pop)
}
}
}
/// `TerminalScreen.closeSession` tmux detach
/// `kill=true` kill-session teardown 250ms
private func closeFromHome(_ s: TerminalSession, kill: Bool) {
withAnimation(TX.Motion.standard) { closing = nil }
if let tmux = s.tmuxController {
if kill { tmux.killSession() } else { tmux.detach() }
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
manager.close(s.id)
}
}
private func content(safeBottom: CGFloat) -> some View {
@@ -85,10 +111,13 @@ struct HomeView: View {
.padding(.top, TX.Space.xs)
sessionCards
}
HStack(alignment: .center) {
HStack(alignment: .center, spacing: TX.Space.s) {
TXSectionLabel(text: "全部主机")
Spacer()
if !groups.isEmpty { chips }
//
TXSearchField(text: $search, placeholder: "搜索主机")
.frame(minWidth: 120, maxWidth: 220)
}
.padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s)
if filteredHosts.isEmpty {
@@ -192,18 +221,9 @@ struct HomeView: View {
.foregroundStyle(p.textWeak)
}
Spacer(minLength: TX.Space.s)
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索主机")
// 220 / iPhone
//
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
// 线
//
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
.padding(.horizontal, gutter)
.padding(.top, TX.Space.l)
@@ -258,11 +278,14 @@ struct HomeView: View {
SessionCard(
session: s,
shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1,
isFocused: manager.focusedID == s.id
) {
manager.focus(s)
router.enter(s.id)
}
isFocused: manager.focusedID == s.id,
onOpen: {
// activate +
manager.activate(s)
router.enter(s.id)
},
onClose: { withAnimation(TX.Motion.standard) { closing = s } }
)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
}
}
@@ -301,12 +324,23 @@ struct HomeView: View {
editing = nil
showEditor = true
} label: {
VStack {
Text("添加主机")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.textWeak)
// = HostTile 30pt +
// **** minHeight 96
VStack(alignment: .leading, spacing: TX.Space.s) {
Color.clear.frame(width: 30, height: 30)
TXBadge(text: " ").opacity(0)
}
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay {
HStack(spacing: TX.Space.xs) {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
Text("添加主机")
.font(TX.Font.mono(12, .medium))
}
.foregroundStyle(p.textWeak)
}
.frame(maxWidth: .infinity, minHeight: 96)
.background(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
@@ -386,6 +420,8 @@ struct SessionCard: View {
let shortcutIndex: Int
let isFocused: Bool
let onOpen: () -> Void
///
let onClose: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
@@ -435,6 +471,19 @@ struct SessionCard: View {
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
// cardHead
// /overlay
.overlay(alignment: .topTrailing) {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 34, height: 36)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
.accessibilityLabel("关闭会话 \(session.displayName)")
}
}
private var cardHead: some View {
@@ -452,6 +501,8 @@ struct SessionCard: View {
}
Spacer(minLength: TX.Space.xs)
linkTag
// overlay
Color.clear.frame(width: 26, height: 1)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 36)
@@ -555,7 +606,8 @@ struct HostTile: View {
let host: SavedHost
let credName: String?
let hasTsnet: Bool
///
/// ****
/// /
let liveSession: TerminalSession?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager