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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user