diff --git a/CLAUDE.md b/CLAUDE.md index f243a1f..93b1ee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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,用户看到的编号是 index(0 起,删过 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 layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 - **客户端格子数的权威 = 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 下 becomeFirstResponder,iPadOS 漏了 → 触摸板点终端永远拿不到焦点(已补,改 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 颜色。 diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 64debd7..0908f11 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -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 + } } } } diff --git a/apps/TerminalX/iOS/HomeView.swift b/apps/TerminalX/iOS/HomeView.swift index 47e0c7c..91abbf9 100644 --- a/apps/TerminalX/iOS/HomeView.swift +++ b/apps/TerminalX/iOS/HomeView.swift @@ -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 diff --git a/apps/TerminalX/iOS/HostsView.swift b/apps/TerminalX/iOS/HostsView.swift index 4ddbb5d..6471d64 100644 --- a/apps/TerminalX/iOS/HostsView.swift +++ b/apps/TerminalX/iOS/HostsView.swift @@ -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) diff --git a/apps/TerminalX/iOS/RailSidebar.swift b/apps/TerminalX/iOS/RailSidebar.swift index 69e2ba7..afbc1fe 100644 --- a/apps/TerminalX/iOS/RailSidebar.swift +++ b/apps/TerminalX/iOS/RailSidebar.swift @@ -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 状态:点在图标列(两态可见),文字是明细。 diff --git a/apps/TerminalX/iOS/SessionManager.swift b/apps/TerminalX/iOS/SessionManager.swift index fd0a904..32fb596 100644 --- a/apps/TerminalX/iOS/SessionManager.swift +++ b/apps/TerminalX/iOS/SessionManager.swift @@ -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 / 用户选了原生 / 连接失败)。 diff --git a/apps/TerminalX/iOS/SessionPickerSheet.swift b/apps/TerminalX/iOS/SessionPickerSheet.swift index 54280cc..0751a33 100644 --- a/apps/TerminalX/iOS/SessionPickerSheet.swift +++ b/apps/TerminalX/iOS/SessionPickerSheet.swift @@ -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") + } } diff --git a/apps/TerminalX/iOS/TXParts.swift b/apps/TerminalX/iOS/TXParts.swift index fcb4a9d..cff9a63 100644 --- a/apps/TerminalX/iOS/TXParts.swift +++ b/apps/TerminalX/iOS/TXParts.swift @@ -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`),于是必须自己让位 —— 底栏在 diff --git a/apps/TerminalX/iOS/TailscaleView.swift b/apps/TerminalX/iOS/TailscaleView.swift index 21485e7..1e944b6 100644 --- a/apps/TerminalX/iOS/TailscaleView.swift +++ b/apps/TerminalX/iOS/TailscaleView.swift @@ -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) } } diff --git a/apps/TerminalX/iOS/TerminalChrome.swift b/apps/TerminalX/iOS/TerminalChrome.swift index 2cb14f5..eb5c4e0 100644 --- a/apps/TerminalX/iOS/TerminalChrome.swift +++ b/apps/TerminalX/iOS/TerminalChrome.swift @@ -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 自己, /// 回首页走侧边栏 / 顶带 ⌄ 最小化)。 diff --git a/apps/TerminalX/iOS/TerminalSession.swift b/apps/TerminalX/iOS/TerminalSession.swift index 3c52064..562b60e 100644 --- a/apps/TerminalX/iOS/TerminalSession.swift +++ b/apps/TerminalX/iOS/TerminalSession.swift @@ -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) { diff --git a/apps/TerminalX/iOS/UIPreviewFixture.swift b/apps/TerminalX/iOS/UIPreviewFixture.swift index f470376..03c3296 100644 --- a/apps/TerminalX/iOS/UIPreviewFixture.swift +++ b/apps/TerminalX/iOS/UIPreviewFixture.swift @@ -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 同义(兼容旧脚本)。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2555a2f..3aab2e3 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 不占位、不 resize;pinned 钉住各 resize 一次)。 - `TerminalSession` 加身份:`id`/`host`/`isLive`(原 showsTerminal,**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态,由 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore、传输层零改动**(50 tests 保持绿)。 -- `SessionManager`:多实例持有、`open(host:)` 复用同主机会话、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。 +- `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` 从生命周期广播里排除,否则 scenePhase→active 广播经 `syncUI()` 把注入状态覆盖回 idle。 - **验收**(四步截图取证,全通过):A 终端会话1 → B `ui/minimize` 回首页(3 会话并存 + mac-studio 标「最小化中」置顶)→ C `ui/session/2` 切会话2(独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样**。 diff --git a/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift b/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift index 3f3eeb2..8a2d0b7 100644 --- a/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift +++ b/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift @@ -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)