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

@@ -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
// truetrue updateUIViewbecomeFirstResponder
.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 {
// panesurface
// 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-catcherTerminalSurfaceView 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
// truetrue 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
}
}
}
}