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

@@ -88,7 +88,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图288pt 全宽排版+railWidth 图标列,收起=左侧裁切列活动会话tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:实心圆✕⌄按钮+弱化标题+`TmuxWindowTabGroup` 无边框 tab 胶囊+右上 mosh/SSH 协议徽标(协议唯一出口,底部状态行不重复);底部只剩状态行=链路+tmux 概览,动作按钮已删;**window/pane 管理交还 tmux 自己**:无新建 window 入口、无 pane 拖拽调宽(连同 `ui/resize` 无头命令、`newWindow`/`resizePane` 一并移除app 只做切换与被动渲染;分屏更早已整体移除)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有`open` 总是新建、`activate` 选中+断线自动重连+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图288pt 全宽排版+railWidth 图标列,收起=左侧裁切列活动会话tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:实心圆✕⌄按钮+弱化标题+`TmuxWindowTabGroup` 无边框 tab 胶囊+右上 mosh/SSH 协议徽标(协议唯一出口,底部状态行不重复);底部只剩状态行=链路+tmux 概览,动作按钮已删;**window/pane 管理交还 tmux 自己**:无新建 window 入口、无 pane 拖拽调宽(连同 `ui/resize` 无头命令、`newWindow`/`resizePane` 一并移除app 只做切换与被动渲染;分屏更早已整体移除)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。
- UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12)`build/mosh/`(手写 config.h+CMakeLists)。
- `artifacts/` — 预编译 xcframework**有意提交**,非 gitignore`CSSHCore`/`TsnetBridge`/`MoshCore`
@@ -106,8 +106,8 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**`tmuxSummary` 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd
- **tmux 不会自己进**`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。**点主机总是新建会话**(会话:主机 = 多:1用户定稿回到旧会话走侧边栏行/首页会话卡 = `SessionManager.activate`,它对已断开的会话**自动重连**并重新走探测判定 tmux/原生)。会话列表持久显示(无论状态),只有用户显式关闭才移除。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端(曾有的「安装引导横幅」已按减法裁掉,状态只在底部状态行一句话)。选择器是**项目风格居中弹框**TXModalScrim非系统 sheet点遮罩=取消本次连接)。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd
- **tmux 不会自己进**`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 静默降级客户端窗口(横幅/安装引导已删,底部状态行写「无 tmux · 客户端窗口」)。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
- **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **客户端格子数的权威 = ghostty 实测,绝不是像素除法**2026-07-26 真机 Claude Code 花屏根因):`容器px ÷ 整数cell px` 复刻不了 ghostty 内部的取整/内边距,实测**偏大 1 行/列**(算得 164×51真实 163×50→ 远端按更宽排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,全屏 TUI 重绘几轮积累成行首碎片。修法:① kickoff 用 raw surface 实测格子(`setClientSize(screenGrid)`,不再被像素换算覆盖);② **单 pane window 的 pane 帧==容器帧,其 resize 上报的实测格子就是客户端格子**`paneViewportChanged``refresh-client -C`(帧只由容器决定、与 tmux 布局无关,故无反馈环);③ 单 pane 时去掉 `TmuxPaneView` 的 1pt padding 圈surface 像素帧必须与 raw/容器完全一致);④ 像素除法只留作「全是多 pane window」时的兜底。验证法`tput cols` 宽的字符标尺应恰好一行不换行(模拟器连本机 sshd 即可复现/回归)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
@@ -116,7 +116,10 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 resize。读 `screenGrid` 的视图(标题胶囊 `164×50`)靠 `GridBox.onGridChange → session.objectWillChange` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。**tmux 模式下 `screenGrid` 冻结**RawTerminalView 已卸载)→ 胶囊改读 `TmuxController.clientGrid`@Published,经 objectWillChange 转发刷新)。
- **`clipped()` 只裁绘制不裁触摸**:收起态下侧边栏被裁掉的明细区(整行按钮)会伸进终端里抢点击 → 整行按钮用 `LeadingHitShape``contentShape` 限制命中区到当前可见宽度Animatable纯明细控件再加 `allowsHitTesting(expanded)`
- **状态变更必须包在 `TX.Motion` 的动画事务里**`AppRouter` 的 route/modal/sidebar 全走 `animated{}`。裸改 `@Published` → 视图上写好的 `.transition` 拿不到事务,**静默退化成硬切**(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走 `View.txShadow(TX.Elevation.*)`,别手写 `.shadow`(深色底上大半径高透明度会糊成脏斑)。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角描边(描边用负 padding `top:-1/trailing:-1` 推出屏外)。Stage Manager 小窗同理。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角描边**四边完整**(右缘落在物理圆角的平直段上无切角问题)——它现在是焦点高亮环,缺一边读作"没画完";旧的「负 padding 推出屏外」只适用当年的灰色装饰边。Stage Manager 小窗同理。
- **软键盘让位必须显式且只让一次**2026-07-26 真机:软键盘一弹终端只剩 6 行):系统隐式 keyboard avoidance 压缩画布一次、`footBand(safeBottom)` 又把键盘高度算进底栏再压一次 = 双重让位。修法:`SessionCanvas .ignoresSafeArea(.keyboard)` 关掉隐式让位,`TerminalScreen``TXKeyboardObserver.height` 对舞台 `.padding(.bottom)` **一次**(键盘可见时 footBand 的 safeBottom 归 0悬浮小键盘报 0 不让位)。
- **终端键盘焦点**`TerminalSession.focusTick` 脉冲route 切到本会话时 +1→ raw / tmux 活动 pane 的 `@FocusState` **先翻 false 再置 true**(下一拍)→ becomeFirstResponder**选中会话即可直接输入**。坑:`terminalFocusOnAppear` 挂载时就把 FocusState 挂在 true 上(当时视图未进 window、聚焦失败直接 `focused = true` 是 true→true **不触发 updateUIView**wrapper 的 `synchronizeFocus` 永远不重试 —— 必须强制翻转surface 就绪(`markSurfaceReady`/`onReady`)后按 `focusEligible`route 正指向本会话)再补一次,覆盖「进终端早于 pane 挂载」的扑空。**妙控触摸板点击是 indirectPointer touch**vendor 的 `UITerminalView.handleIndirectPointerTouches` 原来只在 Catalyst 下 becomeFirstResponderiPadOS 漏了 → 触摸板点终端永远拿不到焦点(已补,改 vendor/libghostty-spm 源)。
- **终端焦点高亮画在 pane 容器边框上**(在线转 accent单 pane 曾自画 accent 描边,但与容器灰边**同位互盖**(左/底被吞、顶边因内容内衬悬空出 10pt 间隔)。单 pane 不再自画描边/圆角,容器是唯一边框;多 pane 保留 pane 间描边。
- **沉浸页的安全区由底栏自己让位**:整屏 `ignoresSafeArea()`,底部区高 = `TX.Layout.footBand(safeBottom)` = 34 + 安全区,内容**居中**(贴住物理底边又让开 home indicator。**坑**:只放开顶边时,颜色型 `.background` 会溢进底部安全区、内容却停在上沿 → 屏幕最下面一条 24pt 空带 + 轨道底色铺满,一眼没对齐;遮罩态/Pin 态面板脚还会差 24pt一钉住就跳。安全区读数用 `TXScreenMetrics``GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0
- **`.frame(height:)` 压不住内容驱动的最小高度**:它只是给了个框,不声明撑满的子视图按自身理想高度**居中**放进去 → 一排卡片高矮不齐。要么子视图自己 `.frame(maxHeight: .infinity)`,要么(内容天生比框高时,如 `TXTerminalMirror` 的行堆叠)把内容放进 `Color.clear``overlay` —— overlay 不参与父视图定尺,尺寸完全由外部决定,溢出交给 `clipped()`
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。

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
}
}
}
}

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

View File

@@ -1,166 +1,10 @@
import SwiftUI
/// split + /
struct HostsListView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: SavedHost?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
// HostEditorView Termius `HostsListView`/`HostCard`/`SessionStatusRow`
// + 稿
//
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
if hostStore.hosts.isEmpty {
TXEmptyState(icon: "server.rack", title: "还没有主机",
subtitle: "点击右上角 + 添加一台服务器")
} else {
ForEach(hostStore.hosts) { host in
HostCard(host: host,
credName: credName(for: host),
tailscaleName: tailscaleName(for: host)) {
// 稿
let s = manager.open(host: host)
router.enter(s.id)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
// manager ObservableObject
if let s = manager.focused { SessionStatusRow(session: s) }
}
.padding(TX.Space.m)
}
}
.navigationTitle("主机")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)"
}
return nil
}
private func tailscaleName(for host: SavedHost) -> String? {
guard let id = host.tailscaleID else { return nil }
return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)"
}
}
/// /
/// `@ObservedObject` `SessionManager`
struct SessionStatusRow: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var isBusy: Bool {
session.phaseText.hasSuffix("") || session.phaseText.contains("重连")
}
var body: some View {
if isBusy || session.phaseText.hasPrefix("失败") {
HStack(spacing: TX.Space.s) {
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
Text(session.phaseText)
.font(.system(size: 13))
.foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary)
}
.padding(.horizontal, 4)
}
}
}
/// + mosh / / / ·Tailscale / chevron
struct HostCard: View {
let host: SavedHost
let credName: String?
let tailscaleName: String?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.m) {
ZStack(alignment: .topTrailing) {
Image(systemName: "server.rack")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(p.textSecondary)
.frame(width: 44, height: 44)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
if host.useMosh {
Circle().fill(p.mosh).frame(width: 9, height: 9)
.overlay(Circle().stroke(p.surface, lineWidth: 2))
.offset(x: 3, y: -3)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(host.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.textPrimary)
Text(host.addressLine)
.font(TX.Font.mono(13))
.foregroundStyle(p.textSecondary)
if credName != nil || tailscaleName != nil {
HStack(spacing: TX.Space.xs) {
if let c = credName { badge("key.fill", c, p.accent) }
if let t = tailscaleName { badge("network", t, p.mosh) }
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.textTertiary)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
.buttonStyle(.txPress)
}
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {
HStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 9))
Text(text).font(.system(size: 11, weight: .medium)).lineLimit(1)
}
.foregroundStyle(color)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(color.opacity(0.13), in: Capsule())
}
}
/// + / Keychain+ Direct / Tailscale+
/// + = Keychain / + / Tailscale+
struct HostEditorView: View {
let existing: SavedHost?
@ObservedObject var credStore: FileCredentialStore
@@ -184,6 +28,7 @@ struct HostEditorView: View {
@State private var tailscaleID: UUID?
@State private var useMosh = false
@State private var showNewCredential = false
@State private var showNewTailscale = false
/// chips
@State private var group = ""
@State private var os = ""
@@ -264,13 +109,19 @@ struct HostEditorView: View {
authMode = .credential
}
}
.sheet(isPresented: $showNewTailscale) {
// Tailscale
TailscaleEditorView(existing: nil, store: tailscaleStore) { conn in
tailscaleID = conn.id
}
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
@ViewBuilder private var authSection: some View {
Section("认证") {
Section {
Picker("方式", selection: $authMode) {
Text("直接输入").tag(AuthMode.inline)
Text("从 Keychain 选择").tag(AuthMode.credential)
@@ -294,18 +145,28 @@ struct HostEditorView: View {
}
.txRow(p)
}
} header: {
Text("认证")
} footer: {
if authMode == .inline {
Text("保存时自动存为 Keychain 凭据(可在 设置 → Keychain 里管理),主机只保留引用。")
}
}
}
@ViewBuilder private var networkSection: some View {
Section("网络(出口)") {
Picker("经由", selection: $tailscaleID) {
Text("Direct直连").tag(UUID?.none)
Text("直连").tag(UUID?.none)
ForEach(tailscaleStore.connections) { conn in
Text(conn.name).tag(UUID?.some(conn.id))
}
}
.txRow(p)
Button { showNewTailscale = true } label: {
Label("新建 Tailscale 连接", systemImage: "plus.circle")
}
.txRow(p)
if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey {
Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。")
.font(.system(size: 12)).foregroundStyle(p.warning).txRow(p)
@@ -330,7 +191,22 @@ struct HostEditorView: View {
private func save() {
let auth: SavedHost.AuthRef
switch authMode {
case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword)
case .inline:
// ** Keychain **
// 稿`inlinePassword`
// /fixture
//
if let dup = credStore.credentials.first(where: { c in
c.kind == .password && c.username == inlineUser
&& credStore.secret(for: c.id) == .password(inlinePassword)
}) {
auth = .credential(id: dup.id)
} else {
let cred = Credential(name: "\(inlineUser)@\(name.isEmpty ? host : name)",
username: inlineUser, kind: .password)
credStore.upsert(cred, secret: .password(inlinePassword))
auth = .credential(id: cred.id)
}
case .credential:
guard let id = selectedCredentialID else { return }
auth = .credential(id: id)

View File

@@ -37,7 +37,6 @@ struct TerminalSidebar: View {
let railWidth: CGFloat
/// home indicator
let safeBottom: CGFloat
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
@@ -64,7 +63,7 @@ struct TerminalSidebar: View {
homeRow
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
sessionRows
newHostRow
//
Spacer(minLength: TX.Space.s)
tailscaleRow
footRow
@@ -182,7 +181,7 @@ struct TerminalSidebar: View {
// MARK: -
@ViewBuilder private var sessionRows: some View {
if manager.activeSessions.isEmpty {
if manager.sessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
@@ -190,44 +189,20 @@ struct TerminalSidebar: View {
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
ForEach(manager.activeSessions) { s in
// **** lastActiveAt
//
ForEach(manager.sessions) { s in
SidebarSessionRow(session: s,
isSelected: router.route.sessionID == s.id,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
manager.focus(s)
// activate + =
manager.activate(s)
router.enter(s.id)
}
}
}
/// 线 +
private var newHostRow: some View {
Button(action: onNewHost) {
HStack(spacing: 0) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 30, height: 30)
.background(
RoundedRectangle(cornerRadius: 9)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
.frame(width: railWidth)
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("连接新主机")
}
// MARK: - tailnet · 线 + 齿
/// tailnet

View File

@@ -70,16 +70,11 @@ final class SessionManager: ObservableObject {
// MARK: - /
/// ****
/// ****
/// **** tmux / `enterRequest`
/// ****稿 =
/// / `activate`
/// **** tmux / `enterRequest`
@discardableResult
func open(host: SavedHost) -> TerminalSession {
if let existing = sessions.first(where: { $0.host?.id == host.id }) {
focus(existing)
enterRequest = existing.id //
return existing
}
let s = makeSession()
sessions.append(s)
focus(s)
@@ -88,6 +83,15 @@ final class SessionManager: ObservableObject {
return s
}
/// **** / / n / URL****
/// 稿 = tmux/
/// / / /
func activate(_ s: TerminalSession) {
focus(s)
guard !s.isSyntheticFixture else { return }
if s.phaseText == "已关闭" || s.phaseText.hasPrefix("失败") { s.retry() }
}
///
///
/// = / tmux / /

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

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
// 稿 / / docs/design/UI-DESIGN-HANDOFF.md
@@ -35,6 +36,41 @@ extension ButtonStyle where Self == TXPressStyle {
static var txPressTight: TXPressStyle { TXPressStyle(scale: 0.9, dim: 0.7) }
}
/// ****
///
/// keyboard avoidance `TX.Layout.footBand(safeBottom)`
/// **** footBand
/// 6 2026-07-26 `.ignoresSafeArea(.keyboard)`
/// `TerminalScreen` padding
/// frame 0
@MainActor
final class TXKeyboardObserver: ObservableObject {
@Published private(set) var height: CGFloat = 0
private var tokens: [NSObjectProtocol] = []
init() {
let nc = NotificationCenter.default
for name in [UIResponder.keyboardWillChangeFrameNotification,
UIResponder.keyboardWillHideNotification] {
tokens.append(nc.addObserver(forName: name, object: nil, queue: .main) { note in
let hide = note.name == UIResponder.keyboardWillHideNotification
let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?
.cgRectValue
Task { @MainActor [weak self] in self?.update(endFrame: end, hidden: hide) }
})
}
}
private func update(endFrame: CGRect?, hidden: Bool) {
guard let endFrame else { return }
let bounds = UIScreen.main.bounds
var overlap = hidden ? 0 : max(0, bounds.maxY - endFrame.minY)
// /
if endFrame.width < bounds.width - 1 { overlap = 0 }
if height != overlap { height = overlap }
}
}
/// +
///
/// 1. ****`ignoresSafeArea`

View File

@@ -72,6 +72,8 @@ struct TailscaleListView: View {
struct TailscaleEditorView: View {
let existing: TailscaleConnection?
@ObservedObject var store: TailscaleStore
///
var onSave: ((TailscaleConnection) -> Void)?
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
@@ -202,5 +204,6 @@ struct TailscaleEditorView: View {
if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) }
else { store.add(conn) }
draft = conn
onSave?(conn)
}
}

View File

@@ -345,89 +345,8 @@ struct TerminalStateOverlay: View {
}
}
/// tmux 稿 `2c` App mosh ** App
/// ** tmux****
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// tmux
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
///
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
}
// tmux tmux tmux
//
/// ****window tmux
/// /

View File

@@ -212,6 +212,9 @@ final class TerminalSession: ObservableObject, Identifiable {
@Published var isMinimized = false
/// +
@Published var lastActiveAt = Date()
/// / +1 becomeFirstResponder
/// onAppear
@Published var focusTick = 0
/// / `syncUI()` phase +
@Published var link: LinkState = .offline
@@ -853,19 +856,8 @@ final class TerminalSession: ObservableObject, Identifiable {
scheduleTmuxBootstrap()
}
///
func dismissTmuxBanner() { tmuxUnavailable = false }
/// tmux
func runInTerminal(_ command: String) {
sendToTerminal(command + "\n")
tmuxUnavailable = false
Task { [weak self] in
try? await Task.sleep(nanoseconds: 8_000_000_000)
guard let self, self.tmuxController == nil else { return }
self.retryTmux()
}
}
// dismissTmuxBanner / runInTerminal tmux
// tmuxUnavailable tmux ·
/// mosh mosh SSH
private func sendToTerminal(_ text: String) {

View File

@@ -211,7 +211,7 @@ enum UIPreviewFixture {
case "session":
guard let n = path.dropFirst().first.flatMap(Int.init),
let s = manager.session(atShortcutIndex: n - 1) else { return false }
manager.focus(s)
manager.activate(s)
router.enter(s.id)
case "sidebar":
// pin toggle

View File

@@ -142,7 +142,8 @@
### 11.5 UI`ContentView` + 4 个 View 文件 + `UIComponents.swift`
- `RootSplitView`=`NavigationSplitView` 侧栏连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情iPhone 紧凑自动坍缩终端改 `.fullScreenCover($model.showsTerminal)` 全屏盖退出回列表状态保留)。
- `HostsView.swift`(列表+HostCard+HostEditorView认证 segmented[直接输入/ Keychain ]+出口 Picker[Direct/ Tailscale])、`KeychainView.swift`(列表+CredentialEditorView密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。
- `HostsView.swift`(现只剩 HostEditorView认证 segmented[直接输入/ Keychain ]+出口 Picker[直连/ Tailscale可就地新建] `HostsListView`/`HostCard`/`SessionStatusRow` 已删——无人引用且"点卡片立即进终端+复用会话"与定稿流程相悖)、`KeychainView.swift`(列表+CredentialEditorView密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP编辑器带 `onSave` 供主机页就地新建后自动选中)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。
- **直接输入自动对象化2026-07-26 用户定稿**主机编辑里直接输入的用户名/密码在保存时**自动落成 Keychain 凭据**同用户名同密码去重复用主机只存 `.credential(id)` 引用`.inlinePassword` 仅保留给启动参数/fixture 合成主机与旧数据迁移Tailscale 同理主机页可新建 Tailscale 连接」(sheet 打开编辑器保存即自动选中)。三对象主机/凭据/Tailscale 连接各自增删改查互相引用
### 11.6 未验证(需用户实时资源)
tsnet 多节点 Up authkey)、密钥/凭据成功登录需服务器信任 key)、mosh over egress plan这些在有凭据时按 §8 流程补测
@@ -174,7 +175,7 @@
### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift``SSHTerminalModel`→`TerminalSession`
- `AppRoute = .home | .terminal(UUID)``SidebarState = .collapsed|.overlay|.pinned`overlay 不占位 resizepinned 钉住各 resize 一次)。
- `TerminalSession` 加身份`id`/`host`/`isLive`( showsTerminal**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore传输层零改动**50 tests 保持绿)。
- `SessionManager`多实例持有`open(host:)` 复用同主机会话`focus`/`minimize`/`close`前后台广播、⌘19 索引首页排序最小化置顶 + 最近活跃
- `SessionManager`多实例持有`open(host:)` **总是新建会话**2026-07-26 用户定稿会话与主机**不是一对一**再点同一主机 = 再开一条并行会话;早期"复用同主机会话"的语义已废除)、`activate(_:)`(选中既有会话:聚焦 + **已断开的自动重连**并重新判定 tmux/原生)、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。会话列表**持久显示**无论连接状态只有用户显式关闭才移除
- `ContentView` `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**详见 CLAUDE.md 摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`每会话一份后台会话也能弹安全告警)。
- 无头验证脚手架DEBUG`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。****假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除否则 scenePhaseactive 广播经 `syncUI()` 把注入状态覆盖回 idle
- **验收**四步截图取证全通过A 终端会话1 B `ui/minimize` 回首页3 会话并存 + mac-studio 最小化中置顶)→ C `ui/session/2` 切会话2独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样**

View File

@@ -120,11 +120,12 @@
}
core.setFocus(true)
#if targetEnvironment(macCatalyst)
if phase == .began {
becomeFirstResponder()
}
#endif
// iPadOS too, not just Catalyst: a Magic Keyboard trackpad click is an
// indirect-pointer touch without this the view never becomes first
// responder, so trackpad users cannot acquire keyboard focus at all.
if phase == .began {
becomeFirstResponder()
}
stopMomentumScrolling()
let button = pointerButton(from: event)