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:
@@ -103,22 +103,21 @@ struct RootView: View {
|
||||
.statusBarHidden(router.route != .home)
|
||||
// 连接流程的一部分:探测出 tmux 会话后在**首页上**弹选择器,选完才进终端。
|
||||
// 挂在 RootView 而不是终端页 —— 用户点主机时还没进终端。
|
||||
.sheet(isPresented: Binding(
|
||||
get: { manager.sessionAwaitingChoice != nil },
|
||||
set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } }
|
||||
)) {
|
||||
// 项目风格的居中**弹框**(曾是系统 sheet:手柄+半屏形态读作"页面",用户裁决改弹框)。
|
||||
// 点遮罩 = 取消本次连接(弹框语义:点外面就是不想连了;原 sheet 下滑 = 原生终端太隐晦)。
|
||||
.overlay {
|
||||
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
|
||||
SessionPickerSheet(
|
||||
SessionPickerDialog(
|
||||
host: s.displayName,
|
||||
choice: choice,
|
||||
onAttach: { s.attachTmuxSession($0) },
|
||||
onCreate: { s.createTmuxSession($0) },
|
||||
onNative: { s.useNativeTerminal() })
|
||||
.environmentObject(theme)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
onNative: { s.useNativeTerminal() },
|
||||
onCancel: { manager.close(s.id) })
|
||||
.transition(TX.Motion.pop)
|
||||
}
|
||||
}
|
||||
.animation(TX.Motion.standard, value: manager.sessionAwaitingChoice?.id)
|
||||
// 会话就绪(选择完成 / 无 tmux / 失败)→ 进终端页。
|
||||
.onChange(of: manager.enterRequest) { _, id in
|
||||
guard let id else { return }
|
||||
@@ -160,6 +159,9 @@ struct SessionCanvas: View {
|
||||
.allowsHitTesting(visible)
|
||||
}
|
||||
}
|
||||
// 关掉系统的隐式键盘让位:终端页的让位由 TerminalScreen 按 TXKeyboardObserver
|
||||
// **显式做一次**(隐式 + footBand 双重让位曾把终端压到 6 行,见 TXParts 注释)。
|
||||
.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +181,8 @@ struct TerminalScreen: View {
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.horizontalSizeClass) private var hSize
|
||||
@State private var sidebarSearch = ""
|
||||
/// 「安装并启用 tmux」引导弹层。
|
||||
@State private var showTmuxInstall = false
|
||||
/// 软键盘高度(显式让位,见 TXKeyboardObserver 注释)。
|
||||
@StateObject private var keyboard = TXKeyboardObserver()
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
@@ -189,6 +191,11 @@ struct TerminalScreen: View {
|
||||
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
// 选中本会话(进终端 / 侧边栏切换)→ 终端自动拿键盘焦点,不必再点一下屏幕
|
||||
// (用户裁决)。SessionCanvas 常驻挂载,onAppear 只在建会话时来一次,切换靠这里。
|
||||
.onChange(of: router.route.sessionID) { _, id in
|
||||
if id == session.id { session.focusTick &+= 1 }
|
||||
}
|
||||
}
|
||||
|
||||
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||||
@@ -199,7 +206,7 @@ struct TerminalScreen: View {
|
||||
TerminalSidebar(manager: manager, router: router,
|
||||
tailscaleStore: tailscaleStore,
|
||||
railWidth: railWidth, safeBottom: safeBottom,
|
||||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
||||
onSettings: goHomeToSettings,
|
||||
search: $sidebarSearch)
|
||||
stage(safeTop: safeTop, safeBottom: safeBottom)
|
||||
}
|
||||
@@ -216,14 +223,6 @@ struct TerminalScreen: View {
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
if showTmuxInstall {
|
||||
TmuxInstallSheet(
|
||||
onRun: { cmd in
|
||||
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
|
||||
session.runInTerminal(cmd)
|
||||
},
|
||||
onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } })
|
||||
}
|
||||
}
|
||||
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
|
||||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||||
@@ -242,9 +241,8 @@ struct TerminalScreen: View {
|
||||
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
||||
}
|
||||
|
||||
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
|
||||
/// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。
|
||||
private func goHomeToAddHost() {
|
||||
/// 侧边栏设置齿轮:首版回首页(那里有设置入口)。
|
||||
private func goHomeToSettings() {
|
||||
router.goHome()
|
||||
}
|
||||
|
||||
@@ -259,14 +257,8 @@ struct TerminalScreen: View {
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
.padding(.bottom, TX.Space.s)
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.transition(.opacity)
|
||||
}
|
||||
// 「未检测到 tmux」横幅已删(用户裁决减法):无 tmux 静默走原生窗口,
|
||||
// 状态只在底部状态行一句话("无 tmux · 客户端窗口"),不打扰。
|
||||
paneArea
|
||||
// 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。
|
||||
.padding(.top, TX.Space.s)
|
||||
@@ -274,17 +266,27 @@ struct TerminalScreen: View {
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 右边与屏幕边缘齐平,那条描边推出屏外:留着就是贴在屏幕边上的
|
||||
// 多余框线,还会在屏幕圆角处被切出斜口。顶边现在朝 chrome,描边保留。
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1))
|
||||
// 终端的**焦点高亮就画在容器边框上**(在线转 accent):单 pane 曾自画
|
||||
// accent 描边,但它与容器边框同位 —— 左/底被容器的灰边盖住、顶边又因
|
||||
// 内容内衬悬空出一条 10pt 间隔(2026-07-26 真机截图)。容器是唯一边框
|
||||
// 后四边干净、无间隔。
|
||||
// 右边不再推出屏外(旧灰边框时代的处理):现在这一圈是焦点高亮,
|
||||
// 缺右边读作"没画完"(用户裁决)。右缘在物理圆角的平直段上,无切角问题。
|
||||
paneShape.strokeBorder(session.isLive ? TXAccent.border : p.border,
|
||||
lineWidth: 1)
|
||||
.animation(TX.Motion.quick, value: session.isLive)
|
||||
)
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(session: session, safeBottom: safeBottom)
|
||||
// 键盘可见时底栏不再额外让 home indicator(那块已被键盘盖住)。
|
||||
TerminalActionBar(session: session,
|
||||
safeBottom: keyboard.height > 0 ? 0 : safeBottom)
|
||||
}
|
||||
// 软键盘的**唯一**让位:整个舞台(含底栏)抬到键盘/输入附件条之上。
|
||||
.padding(.bottom, keyboard.height)
|
||||
.background(p.bgDeep)
|
||||
.animation(TX.Motion.standard, value: keyboard.height)
|
||||
}
|
||||
|
||||
/// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。
|
||||
@@ -304,9 +306,11 @@ struct TerminalScreen: View {
|
||||
@ViewBuilder private var paneArea: some View {
|
||||
ZStack {
|
||||
if let tmux = session.tmuxController {
|
||||
TmuxStage(controller: tmux)
|
||||
TmuxStage(controller: tmux, focusTick: session.focusTick,
|
||||
focusEligible: router.route.sessionID == session.id)
|
||||
} else {
|
||||
RawTerminalView(model: session)
|
||||
RawTerminalView(model: session,
|
||||
focusEligible: router.route.sessionID == session.id)
|
||||
}
|
||||
// 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。
|
||||
TerminalStateOverlay(session: session,
|
||||
@@ -353,16 +357,32 @@ struct TerminalScreen: View {
|
||||
/// 普通(非 tmux)单终端视图。
|
||||
struct RawTerminalView: View {
|
||||
@ObservedObject var model: TerminalSession
|
||||
/// 本会话是否正被路由展示(焦点只给看得见的终端,别从前台会话手里抢键盘)。
|
||||
let focusEligible: Bool
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: model.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
// 选中本会话(进终端/侧边栏切换)→ 自动拿键盘焦点。**必须先翻 false 再置 true**:
|
||||
// onAppear 早把 FocusState 挂在 true 上(当时视图未进 window、聚焦失败),
|
||||
// true→true 不触发 updateUIView,becomeFirstResponder 永远不会重试。
|
||||
.onChange(of: model.focusTick) { _, _ in
|
||||
focused = false
|
||||
Task { @MainActor in focused = true }
|
||||
}
|
||||
.task {
|
||||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
}
|
||||
model.markSurfaceReady()
|
||||
// surface 就绪补一次聚焦(此刻必已进 window):连接建立早于进终端时,
|
||||
// route 变化的 focusTick 脉冲可能先于本视图挂载而扑空。
|
||||
if focusEligible {
|
||||
focused = false
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +391,10 @@ struct RawTerminalView: View {
|
||||
/// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。
|
||||
struct TmuxStage: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
/// 键盘焦点脉冲(TerminalSession.focusTick 直通):变化时活动 pane 自动拿焦点。
|
||||
let focusTick: Int
|
||||
/// 本会话是否正被路由展示(焦点只给看得见的终端)。
|
||||
let focusEligible: Bool
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
private var p: TXPalette { theme.palette }
|
||||
@@ -391,7 +415,8 @@ struct TmuxStage: View {
|
||||
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
|
||||
ZStack {
|
||||
ForEach(controller.windows) { win in
|
||||
TmuxWindowView(window: win, controller: controller)
|
||||
TmuxWindowView(window: win, controller: controller,
|
||||
focusTick: focusTick, focusEligible: focusEligible)
|
||||
.opacity(win.id == controller.activeWindowID ? 1 : 0)
|
||||
.allowsHitTesting(win.id == controller.activeWindowID)
|
||||
}
|
||||
@@ -418,6 +443,8 @@ struct TmuxStage: View {
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
let focusTick: Int
|
||||
let focusEligible: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
@@ -436,6 +463,8 @@ struct TmuxWindowView: View {
|
||||
// 单 pane:surface 帧必须与容器完全一致(其实测格子是
|
||||
// 客户端尺寸的权威,见 paneViewportChanged),不留缝。
|
||||
solo: items.count == 1,
|
||||
focusTick: focusTick,
|
||||
focusEligible: focusEligible,
|
||||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||||
onReady: { controller.markPaneReady(item.pane) }
|
||||
)
|
||||
@@ -470,6 +499,10 @@ struct TmuxPaneView: View {
|
||||
let isActive: Bool
|
||||
/// window 里只有这一个 pane:不留 pane 缝,surface 像素帧 == 容器帧(尺寸权威所需)。
|
||||
let solo: Bool
|
||||
/// 键盘焦点脉冲:变化且本 pane 活动时自动 becomeFirstResponder。
|
||||
let focusTick: Int
|
||||
/// 本会话是否正被路由展示(焦点只给看得见的终端)。
|
||||
let focusEligible: Bool
|
||||
let onTap: () -> Void
|
||||
let onReady: () -> Void
|
||||
@FocusState private var focused: Bool
|
||||
@@ -479,12 +512,16 @@ struct TmuxPaneView: View {
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: surface.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(isActive ? TXAccent.border : p.divider,
|
||||
lineWidth: isActive ? 1 : 0.5)
|
||||
)
|
||||
// 单 pane 不自画圆角与描边:容器(paneShape)是唯一边框/高亮(同位双描边
|
||||
// 会互相盖,见 TerminalScreen.paneArea 注释)。多 pane 保留 pane 间描边。
|
||||
.clipShape(RoundedRectangle(cornerRadius: solo ? 0 : 6))
|
||||
.overlay {
|
||||
if !solo {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(isActive ? TXAccent.border : p.divider,
|
||||
lineWidth: isActive ? 1 : 0.5)
|
||||
}
|
||||
}
|
||||
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
|
||||
.overlay {
|
||||
if !isActive {
|
||||
@@ -493,11 +530,25 @@ struct TmuxPaneView: View {
|
||||
}
|
||||
.padding(solo ? 0 : TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt;单 pane 不缩
|
||||
.onChange(of: isActive) { _, active in focused = active }
|
||||
// 选中本会话(进终端/侧边栏切换)→ 活动 pane 自动拿键盘焦点。
|
||||
// **先翻 false 再置 true**:FocusState 可能停留在挂载时的 true(当时聚焦失败),
|
||||
// true→true 不触发 updateUIView(见 RawTerminalView 同款注释)。
|
||||
.onChange(of: focusTick) { _, _ in
|
||||
guard isActive else { return }
|
||||
focused = false
|
||||
Task { @MainActor in focused = true }
|
||||
}
|
||||
.task {
|
||||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
}
|
||||
onReady()
|
||||
// surface 就绪补一次聚焦(进终端早于 pane 挂载时,focusTick 脉冲会扑空)。
|
||||
if focusEligible, isActive {
|
||||
focused = false
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user