Compare commits
4 Commits
5707d70dff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5292107b | ||
|
|
8da725114f | ||
|
|
e59051a6de | ||
|
|
5d1abdc7ac |
24
CLAUDE.md
24
CLAUDE.md
@@ -52,7 +52,7 @@ APP=~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Release-iph
|
||||
xcrun devicectl device install app --device $IPAD "$APP"
|
||||
xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx
|
||||
```
|
||||
自动签名已配好(Team J75A6S5938)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 SwiftUI 开销会让动效发闷)。
|
||||
自动签名已配好(Team J75A6S5938)。**LAN 直连需要「本地网络」权限**:Info.plist 已带 `NSLocalNetworkUsageDescription`(没有它系统可能不弹框直接静默拒绝);重装后首次连 LAN 主机会弹授权框,被拒的表现是 **socket 纯超时(约 12s),别的 app 正常**——去 设置→隐私与安全性→本地网络 打开开关(2026-07-26 真机踩坑)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 SwiftUI 开销会让动效发闷)。
|
||||
|
||||
## 无头验证(本环境无 GUI,用 simctl 截图取证)
|
||||
iPad Pro 13(M5) 模拟器 UDID:`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
|
||||
@@ -75,9 +75,9 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
|
||||
- **M4 host key mismatch 测试**:`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
|
||||
|
||||
**UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
|
||||
- **`-txLandscape 1`**:app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024)。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
|
||||
- **`-txLandscape 1`**:app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024)。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。**⚠️ 大坑(2026-07-26):帧缓冲不随窗口变横**——窗口按 1376×1032 排版但帧缓冲只显示**左边 1032pt**,右侧 ~344pt 直接落在屏外:顶带 trailing 的徽标/按钮在截图里"消失",看起来像没渲染(曾为此追了 6 轮假 bug:不是布局/条件/z 序问题,是根本没拍到)。**验证贴右边缘的 UI 必须去掉 `-txLandscape` 用竖屏**(窗口 1032×1376 完整落在帧缓冲内);横屏全宽对拍只能真机/带 GUI 环境做。
|
||||
- **`-txUIFixture 1`**:造 3 条**假会话**(mac-studio/ubuntu-cn2/nas-home,含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`(DEBUG only),与 `-txHost` 互斥。
|
||||
- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
|
||||
- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `dismiss` / `session/<n>` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍;`split*` 已随「放弃移动端分屏」删除)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
|
||||
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。
|
||||
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView` 按 `router.route` 判断。
|
||||
- **`-txSlowMotion 1`**:所有动效 ×12(DEBUG)。`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到**三个不同的中间值**;硬切时三张一模一样。
|
||||
@@ -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 图标列,收起=左侧裁切)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`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,22 +106,26 @@ 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」。
|
||||
- **tmux 多 pane 尺寸走"路线 b"**:app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱);pane surface 的 resize 回调**只断言不反向驱动 tmux**(防反馈环)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(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) 单消费者。
|
||||
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除,ghostty 释放 grid,内容全丢;② matchedGeometry(最小化缩回首页卡)要求两端同层级,独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
|
||||
- **侧边栏两态(收起⇄固定展开,悬浮态已被用户裁决砍掉)必须是同一个视图身份**(`TerminalSidebar`):内容恒按 288pt 全宽排版,收起只是容器宽度收到 railWidth + `clipped()`("收起=展开在左侧裁切");最左 railWidth 是图标列,两态同尺寸同位置,明细随展开淡入;视图直接参与布局,收起=明细淡出+终端从右盖上来(用户定稿效果)。开合入口只有两个:**Logo 位**(收起态显 » 展开键,展开态还原 Logo——将来 App Logo 设计与展开按钮一体)与头部 « 收起键。曾经"轨道+面板两套视图 + overlay/pinned 两个面板实例",三态切换视图身份互换 → 动画混乱硬切。
|
||||
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 resize。读 `screenGrid` 的视图(标题胶囊 `164×50`)靠 `GridBox.onGridChange → session.objectWillChange` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。
|
||||
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 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 颜色。
|
||||
- **嵌套 ObservableObject 不冒泡**:`SessionManager` 的 `@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
|
||||
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
|
||||
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
|
||||
- 提交/推送 git 只在用户明确要求时(main 分支已有 6 次提交,未 push、无 remote)。
|
||||
- 提交/推送 git 只在用户明确要求时;远端 `origin` 已配置(git@git.njcqit.com:2222/eric/terminalX.git),推送前用 `git status`/`git log origin/main..HEAD` 现查领先状态,不要凭记忆判断。
|
||||
- 与用户始终用**简体中文**交流。
|
||||
</content>
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
<string>JetBrainsMono-SemiBold.ttf</string>
|
||||
<string>JetBrainsMono-Bold.ttf</string>
|
||||
</array>
|
||||
<!-- 本地网络权限(iOS 14+):SSH/mosh 直连 192.168.x.x 等 LAN 主机必须。
|
||||
没有这个键,新装/重装后系统可能不弹授权框直接静默拒绝 —— 表现为 socket 纯超时
|
||||
(2026-07-26 真机踩坑:devicectl 重装后连 LAN 主机 12s 超时,别的 app 正常)。 -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>terminalX 需要访问本地网络,才能通过 SSH/mosh 连接局域网内的服务器与设备。</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -32,13 +32,11 @@ enum SidebarState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动
|
||||
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W 快捷键与无头 URL 驱动
|
||||
/// 都要能打开它,视图内部状态触达不到。
|
||||
enum AppModal: Equatable {
|
||||
/// 关闭确认(3c)
|
||||
case closeConfirm
|
||||
/// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D)
|
||||
case splitMenu(vertical: Bool)
|
||||
}
|
||||
|
||||
/// 导航状态持有者。视图只读它、只经方法改它。
|
||||
@@ -46,13 +44,13 @@ enum AppModal: Equatable {
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published var route: AppRoute = .home
|
||||
@Published var sidebar: SidebarState = .collapsed
|
||||
/// 当前模态浮层(关闭确认 / 分屏菜单)。
|
||||
/// 当前模态浮层(关闭确认)。
|
||||
@Published var modal: AppModal?
|
||||
|
||||
/// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms easeOut,响应「减弱动态」)。
|
||||
///
|
||||
/// **路由 / 侧边栏 / 浮层的每一次变更都必须包在这条曲线里**,否则视图上的 `transition`
|
||||
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认、分屏菜单曾都是这么"啪"地跳出来的。
|
||||
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认曾都是这么"啪"地跳出来的。
|
||||
/// 收在 router 里的另一个好处:点击、⌘ 快捷键、无头 URL 三条入口天然同一手感。
|
||||
private func animated(_ change: () -> Void) {
|
||||
withAnimation(TX.Motion.standard, change)
|
||||
@@ -76,7 +74,7 @@ final class AppRouter: ObservableObject {
|
||||
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
|
||||
}
|
||||
|
||||
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
||||
/// 打开/关闭模态浮层(红点、⌘W 与无头 URL 共用同一入口)。
|
||||
func present(_ modal: AppModal) { animated { self.modal = modal } }
|
||||
func dismissModal() { animated { modal = nil } }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -196,15 +203,15 @@ struct TerminalScreen: View {
|
||||
// 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的
|
||||
// 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起),
|
||||
// 反之展开。开合各触发一次终端 resize。
|
||||
TerminalSidebar(manager: manager, router: router, session: session,
|
||||
TerminalSidebar(manager: manager, router: router,
|
||||
tailscaleStore: tailscaleStore,
|
||||
railWidth: railWidth, safeBottom: safeBottom,
|
||||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
||||
onSettings: goHomeToSettings,
|
||||
search: $sidebarSearch)
|
||||
stage(safeTop: safeTop, safeBottom: safeBottom)
|
||||
}
|
||||
.background(p.bgDeep)
|
||||
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
// 关闭确认(3c):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
.overlay {
|
||||
switch router.modal {
|
||||
case .closeConfirm:
|
||||
@@ -213,24 +220,9 @@ struct TerminalScreen: View {
|
||||
onDetach: { router.dismissModal(); closeSession(kill: false) },
|
||||
onKill: { router.dismissModal(); closeSession(kill: true) },
|
||||
onCancel: { router.dismissModal() })
|
||||
case .splitMenu(let vertical):
|
||||
SplitMenuView(session: session, vertical: vertical,
|
||||
onPick: { target in
|
||||
router.dismissModal()
|
||||
performSplit(target, vertical: vertical)
|
||||
},
|
||||
onCancel: { router.dismissModal() })
|
||||
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(
|
||||
@@ -249,56 +241,60 @@ struct TerminalScreen: View {
|
||||
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
||||
}
|
||||
|
||||
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
|
||||
/// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。
|
||||
private func goHomeToAddHost() {
|
||||
/// 侧边栏设置齿轮:首版回首页(那里有设置入口)。
|
||||
private func goHomeToSettings() {
|
||||
router.goHome()
|
||||
}
|
||||
|
||||
// MARK: - 舞台
|
||||
|
||||
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.padding(.top, TX.Layout.capsuleInset)
|
||||
.transition(.opacity)
|
||||
}
|
||||
// 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上;
|
||||
// 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。
|
||||
// 顶带(真实布局行,用户定稿):✕/⌄ 按钮组 + 弱化标题 + tmux window tab 组,
|
||||
// 全在终端**外**的 chrome 带上 —— 终端 pane 从带下方开始,不再被悬浮胶囊遮内容。
|
||||
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
.padding(.bottom, TX.Space.s)
|
||||
// 「未检测到 tmux」横幅已删(用户裁决减法):无 tmux 静默走原生窗口,
|
||||
// 状态只在底部状态行一句话("无 tmux · 客户端窗口"),不打扰。
|
||||
paneArea
|
||||
// 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。
|
||||
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
|
||||
// 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。
|
||||
.padding(.top, TX.Space.s)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕
|
||||
// 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。
|
||||
.padding(EdgeInsets(top: -1, 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 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
|
||||
}
|
||||
capsules(safeTop: safeTop)
|
||||
// 键盘可见时底栏不再额外让 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 的那几个角收圆角**。
|
||||
///
|
||||
/// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 终端右侧贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。
|
||||
/// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条)
|
||||
/// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。
|
||||
/// 故右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、顶边(挨顶带)、
|
||||
/// 底边(挨提示条)才是真正需要圆角把 pane 和 chrome 分开的地方。
|
||||
private var paneShape: UnevenRoundedRectangle {
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: TX.Radius.pane,
|
||||
@@ -310,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,
|
||||
@@ -328,17 +326,6 @@ struct TerminalScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func capsules(safeTop: CGFloat) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
Spacer(minLength: TX.Space.m)
|
||||
TerminalStatusCapsule(session: session)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
}
|
||||
|
||||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||||
private func requestClose() {
|
||||
router.present(.closeConfirm)
|
||||
@@ -365,69 +352,37 @@ struct TerminalScreen: View {
|
||||
router.goHome()
|
||||
}
|
||||
|
||||
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
|
||||
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
|
||||
guard let tmux = session.tmuxController else { return }
|
||||
switch target {
|
||||
case .currentSession:
|
||||
tmux.splitWindow(vertical: vertical)
|
||||
case .otherSession(let name):
|
||||
tmux.splitWindow(vertical: vertical, joinSession: name)
|
||||
case .newSession:
|
||||
// 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。
|
||||
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
|
||||
case .nativeWindow:
|
||||
break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。
|
||||
private func requestSplit(vertical: Bool) {
|
||||
session.tmuxController?.listSessions()
|
||||
router.present(.splitMenu(vertical: vertical))
|
||||
}
|
||||
|
||||
/// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」——
|
||||
/// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。
|
||||
private var barContext: String {
|
||||
if !session.isLive { return session.phaseText }
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
|
||||
return session.displayName
|
||||
}
|
||||
|
||||
/// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。
|
||||
private var barActions: [TerminalActionBar.Action] {
|
||||
let tmux = session.tmuxController
|
||||
return [
|
||||
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
|
||||
requestSplit(vertical: true)
|
||||
},
|
||||
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
|
||||
enabled: tmux != nil) {
|
||||
tmux?.newWindow()
|
||||
},
|
||||
.init(id: "home", icon: "house", title: "回首页") { router.goHome() },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通(非 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,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 }
|
||||
@@ -456,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)
|
||||
}
|
||||
@@ -477,10 +437,14 @@ struct TmuxStage: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)+ pane 间拖拽条。
|
||||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
|
||||
/// pane 尺寸调整**不做 app 侧 UI**(第一版减法,用户裁决):交给 tmux 自己(prefix+方向键等),
|
||||
/// app 只被动跟随 `%layout-change`。
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
let focusTick: Int
|
||||
let focusEligible: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
@@ -489,12 +453,18 @@ struct TmuxWindowView: View {
|
||||
let root = layout.root.rect
|
||||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||||
let ch = geo.size.height / CGFloat(max(1, root.height))
|
||||
let items = leaves(layout.root)
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||||
ForEach(items, id: \.pane.raw) { item in
|
||||
if let surface = window.panes[item.pane] {
|
||||
TmuxPaneView(
|
||||
surface: surface,
|
||||
isActive: window.activePane == item.pane,
|
||||
// 单 pane:surface 帧必须与容器完全一致(其实测格子是
|
||||
// 客户端尺寸的权威,见 paneViewportChanged),不留缝。
|
||||
solo: items.count == 1,
|
||||
focusTick: focusTick,
|
||||
focusEligible: focusEligible,
|
||||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||||
onReady: { controller.markPaneReady(item.pane) }
|
||||
)
|
||||
@@ -504,10 +474,6 @@ struct TmuxWindowView: View {
|
||||
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
|
||||
}
|
||||
}
|
||||
// pane 之间的拖拽条(在 pane 之上,热区 12pt)。
|
||||
ForEach(splitters(layout.root), id: \.id) { s in
|
||||
PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
@@ -525,132 +491,18 @@ struct TmuxWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**,
|
||||
/// 故 resize 目标取"前一个子节点里的任一 pane"即可。
|
||||
private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] {
|
||||
var out: [SplitterSpec] = []
|
||||
func walk(_ n: TmuxLayout.Node) {
|
||||
switch n {
|
||||
case .leaf:
|
||||
break
|
||||
case .horizontal(let children, _):
|
||||
// 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。
|
||||
for (i, child) in children.enumerated() where i < children.count - 1 {
|
||||
let r = child.rect
|
||||
if let pane = firstPane(child) {
|
||||
out.append(SplitterSpec(
|
||||
id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane,
|
||||
x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width))
|
||||
}
|
||||
}
|
||||
children.forEach(walk)
|
||||
case .vertical(let children, _):
|
||||
// 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。
|
||||
for (i, child) in children.enumerated() where i < children.count - 1 {
|
||||
let r = child.rect
|
||||
if let pane = firstPane(child) {
|
||||
out.append(SplitterSpec(
|
||||
id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane,
|
||||
x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height))
|
||||
}
|
||||
}
|
||||
children.forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return out
|
||||
}
|
||||
|
||||
private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? {
|
||||
switch node {
|
||||
case .leaf(let p, _): return p
|
||||
case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条分隔位置(格子坐标)。
|
||||
struct SplitterSpec {
|
||||
let id: String
|
||||
/// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高)
|
||||
let isVertical: Bool
|
||||
/// resize 目标 pane(前一个子节点里的任一 pane)
|
||||
let pane: TmuxPaneID
|
||||
let x: Int, y: Int
|
||||
/// 条的长度(格子)
|
||||
let length: Int
|
||||
/// 前一个子节点当前的 cols / rows
|
||||
let currentSize: Int
|
||||
}
|
||||
|
||||
/// pane 拖拽条:热区 12pt、把手 44×3pt。
|
||||
///
|
||||
/// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**,
|
||||
/// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。
|
||||
struct PaneSplitter: View {
|
||||
let splitter: SplitterSpec
|
||||
let cellW: CGFloat
|
||||
let cellH: CGFloat
|
||||
let controller: TmuxController
|
||||
@State private var drag: CGFloat = 0
|
||||
@State private var dragging = false
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
private var hit: CGFloat { TX.Layout.paneDragHit }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if splitter.isVertical {
|
||||
bar(width: hit, height: CGFloat(splitter.length) * cellH)
|
||||
.position(x: CGFloat(splitter.x) * cellW + drag,
|
||||
y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH)
|
||||
} else {
|
||||
bar(width: CGFloat(splitter.length) * cellW, height: hit)
|
||||
.position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW,
|
||||
y: CGFloat(splitter.y) * cellH + drag)
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 2)
|
||||
.onChanged { v in
|
||||
dragging = true
|
||||
drag = splitter.isVertical ? v.translation.width : v.translation.height
|
||||
}
|
||||
.onEnded { _ in
|
||||
// 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。
|
||||
let cell = splitter.isVertical ? cellW : cellH
|
||||
let delta = cell > 0 ? Int((drag / cell).rounded()) : 0
|
||||
let newSize = max(1, splitter.currentSize + delta)
|
||||
if delta != 0 {
|
||||
controller.resizePane(splitter.pane,
|
||||
cols: splitter.isVertical ? newSize : nil,
|
||||
rows: splitter.isVertical ? nil : newSize)
|
||||
}
|
||||
drag = 0
|
||||
dragging = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。
|
||||
private func bar(width: CGFloat, height: CGFloat) -> some View {
|
||||
ZStack {
|
||||
Color.clear.frame(width: width, height: height)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(dragging ? TXAccent.base : p.borderStrong)
|
||||
.frame(width: splitter.isVertical ? 3 : 44,
|
||||
height: splitter.isVertical ? 44 : 3)
|
||||
.opacity(dragging ? 1 : 0.55)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。
|
||||
struct TmuxPaneView: View {
|
||||
@ObservedObject var surface: TmuxPaneSurface
|
||||
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
|
||||
@@ -660,25 +512,43 @@ struct TmuxPaneView: View {
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: surface.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
// 单 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 {
|
||||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
.padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +221,10 @@ 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 }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, gutter)
|
||||
.padding(.top, TX.Space.l)
|
||||
.padding(.bottom, TX.Space.m)
|
||||
@@ -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)
|
||||
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 {
|
||||
// 高度骨架 = 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)
|
||||
@@ -461,7 +512,7 @@ struct SessionCard: View {
|
||||
@ViewBuilder private var linkTag: some View {
|
||||
switch session.link {
|
||||
case .direct:
|
||||
tag("direct", p.online)
|
||||
tag("直连", p.online)
|
||||
case .relay:
|
||||
tag("tsnet", p.online)
|
||||
case .connecting:
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,13 +5,13 @@ import SwiftUI
|
||||
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
|
||||
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
|
||||
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 /
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / window 徽标 /
|
||||
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
|
||||
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
|
||||
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
|
||||
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
|
||||
//
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/window 行)在
|
||||
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
|
||||
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
|
||||
// `allowsHitTesting(expanded)`。
|
||||
@@ -32,14 +32,11 @@ private struct LeadingHitShape: Shape {
|
||||
struct TerminalSidebar: View {
|
||||
@ObservedObject var manager: SessionManager
|
||||
@ObservedObject var router: AppRouter
|
||||
/// 当前会话(tmux 段列它的 window)。
|
||||
@ObservedObject var session: TerminalSession
|
||||
@ObservedObject var tailscaleStore: TailscaleStore
|
||||
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||||
let railWidth: CGFloat
|
||||
/// home indicator 安全区(沉浸页铺满物理屏,由底栏自己让位)。
|
||||
let safeBottom: CGFloat
|
||||
let onNewHost: () -> Void
|
||||
let onSettings: () -> Void
|
||||
@Binding var search: String
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@@ -56,6 +53,8 @@ struct TerminalSidebar: View {
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// 活动会话(主机连接)的切换在侧边栏;tmux window 切换已上移为终端顶部 Tab 条
|
||||
// (TmuxWindowTabGroup,用户定稿),侧边栏不再重复列 window。
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
@@ -64,10 +63,7 @@ struct TerminalSidebar: View {
|
||||
homeRow
|
||||
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
|
||||
sessionRows
|
||||
newHostRow
|
||||
tmuxDivider
|
||||
tmuxLabelRow
|
||||
tmuxRows
|
||||
// 「连接新主机」入口已删(用户裁决第一版减法):新建连接只在首页。
|
||||
Spacer(minLength: TX.Space.s)
|
||||
tailscaleRow
|
||||
footRow
|
||||
@@ -185,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)
|
||||
@@ -193,139 +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: - tmux 段
|
||||
|
||||
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
|
||||
private var tmuxDivider: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
shortDivider
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.padding(.vertical, TX.Space.s)
|
||||
}
|
||||
|
||||
/// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写);
|
||||
/// 展开 = 完整分组标题(tmux · 主机 · 会话名)。
|
||||
private var tmuxLabelRow: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
|
||||
.font(TX.Font.mono(9, .semibold))
|
||||
.tracking(0.9)
|
||||
.foregroundStyle(TXAccent.mid)
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
TXSectionLabel(text: tmuxHeader)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 22)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder private var tmuxRows: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
ForEach(tmux.windows) { win in
|
||||
SidebarWindowRow(window: win,
|
||||
isSelected: win.id == tmux.activeWindowID,
|
||||
expanded: expanded, railWidth: railWidth,
|
||||
hitWidth: currentWidth) {
|
||||
tmux.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
newWindowRow { tmux.newWindow() }
|
||||
} else if let summary = session.tmuxSummary {
|
||||
// 已知是 tmux 会话但 controller 还没 attach:按概览占位,保持两态节奏稳定。
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowRowBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
|
||||
subtitle: i == 0 && summary.panes > 0
|
||||
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
|
||||
isSelected: i == 0,
|
||||
expanded: expanded, railWidth: railWidth)
|
||||
}
|
||||
newWindowRow {}
|
||||
} else {
|
||||
Text("无 tmux · 客户端窗口")
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.padding(.leading, railWidth)
|
||||
.frame(height: 32, alignment: .leading)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
|
||||
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: railWidth, height: 1)
|
||||
Text("新建 window")
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text4)
|
||||
Spacer(minLength: 0)
|
||||
Text("⌘T")
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
.padding(.trailing, TX.Space.m)
|
||||
}
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
.allowsHitTesting(expanded)
|
||||
}
|
||||
|
||||
private var tmuxHeader: String {
|
||||
let host = session.displayName
|
||||
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
|
||||
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
|
||||
}
|
||||
|
||||
// MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮)
|
||||
|
||||
/// tailnet 状态:点在图标列(两态可见),文字是明细。
|
||||
@@ -391,6 +268,7 @@ enum RailSidebarStyle {
|
||||
}
|
||||
|
||||
// MARK: - 行
|
||||
// (window 行已随「window 切换上移为顶部 Tab」移除,见 TerminalChrome.TmuxWindowTabGroup。)
|
||||
|
||||
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
|
||||
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
|
||||
@@ -471,88 +349,3 @@ struct SidebarSessionRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// window 行:图标列 26×22 编号徽标(两态同位,选中态徽标自己高亮)+ 明细(title + cwd/panes)。
|
||||
struct SidebarWindowRow: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isSelected: Bool
|
||||
let expanded: Bool
|
||||
let railWidth: CGFloat
|
||||
let hitWidth: CGFloat
|
||||
let onTap: () -> Void
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = []
|
||||
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
|
||||
let n = window.panes.count
|
||||
if n > 1 { parts.append("\(n) panes") }
|
||||
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
|
||||
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
|
||||
.contentShape(LeadingHitShape(width: hitWidth))
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
/// window 行的视觉本体(SidebarWindowRow 与占位行共用)。
|
||||
struct WindowRowBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let isSelected: Bool
|
||||
let expanded: Bool
|
||||
let railWidth: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(index)
|
||||
.font(TX.Font.mono(11, .semibold))
|
||||
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
|
||||
.frame(width: 26, height: 22)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.frame(width: railWidth)
|
||||
HStack(spacing: TX.Space.s) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? p.text : p.text2)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if isSelected {
|
||||
TXStatusDot(color: p.online, size: 7)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.fill(TXAccent.selectedBg.opacity(0.75))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.opacity(isSelected && expanded ? 1 : 0)
|
||||
)
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 / 用户选了原生 / 连接失败)。
|
||||
@@ -166,8 +170,7 @@ final class SessionManager: ObservableObject {
|
||||
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
|
||||
let s = makeSession()
|
||||
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
|
||||
mosh: host.useMosh, tmux: spec.tmux,
|
||||
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
|
||||
mosh: host.useMosh, tmux: spec.tmux)
|
||||
// 活跃时刻错开,让首页卡片排序稳定可对拍。
|
||||
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
|
||||
sessions.append(s)
|
||||
|
||||
@@ -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 {
|
||||
TXModalScrim(onDismiss: onCancel) {
|
||||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||||
grabber
|
||||
title
|
||||
header
|
||||
if choice.sessions.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
sessionList
|
||||
}
|
||||
newSessionRow
|
||||
Spacer(minLength: 0)
|
||||
nativeRow
|
||||
}
|
||||
.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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`),于是必须自己让位 —— 底栏在
|
||||
@@ -171,9 +207,10 @@ enum TXTech {
|
||||
|
||||
/// 徽标胶囊(主机卡的 mosh / SSH / tsnet / 凭据名)。
|
||||
///
|
||||
/// `tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色,**只留给 tsnet**
|
||||
/// —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签,保持中性,
|
||||
/// 免得一排徽标全是彩色反而没有重点。
|
||||
/// 样式统一为**淡染底 + 同色文字、无描边**(Shimo 终端右上角的徽标语言,用户裁决全局统一,
|
||||
/// 线框样式废除)。`tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色,
|
||||
/// **只留给 tsnet** —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签,
|
||||
/// 保持中性,免得一排徽标全是彩色反而没有重点。
|
||||
struct TXBadge: View {
|
||||
let text: String
|
||||
var tint: Color?
|
||||
@@ -182,28 +219,22 @@ struct TXBadge: View {
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) }
|
||||
private var stroke: Color {
|
||||
if emphasized { return TXAccent.selectedStroke }
|
||||
return tint?.opacity(0.35) ?? .clear
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(TX.Font.mono(11, .medium))
|
||||
.foregroundStyle(fg)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background {
|
||||
let shape = RoundedRectangle(cornerRadius: 5)
|
||||
if emphasized {
|
||||
shape.fill(TXAccent.selectedBg)
|
||||
Capsule().fill(TXAccent.selectedBg)
|
||||
} else if let tint {
|
||||
shape.fill(tint.opacity(0.12))
|
||||
Capsule().fill(tint.opacity(0.14))
|
||||
} else {
|
||||
shape.fill(p.surface)
|
||||
Capsule().fill(p.surface)
|
||||
}
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||||
// 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在
|
||||
// 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。
|
||||
//
|
||||
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||
// 视觉语言对齐 Shimo 参考(用户第二轮裁决):**实心软圆按钮 + 无边框淡染胶囊**,
|
||||
// 全带不再出现描边方框 —— 分组靠留白与底色深浅,不靠画框。
|
||||
// [● ● 实心圆按钮] · 弱化标题(· 分隔的一句话) · [无边框 tab 胶囊组] …… [Mosh/SSH 徽标]
|
||||
// tab 组 = tmux window 的切换胶囊(window 映射原生 tab 的惯例);活动会话切换在侧边栏。
|
||||
|
||||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||
/// 终端顶带:实心圆窗口按钮 + 弱化标题 + tmux window tab 胶囊 + 传输徽标。
|
||||
///
|
||||
/// **扁平**:分层交给描边和材质,投影只留一点点(`TX.Elevation.floating`)。原先那道
|
||||
/// `0 10px 30px rgba(0,0,0,.55)` 在终端文字上糊出一大团脏斑 —— 胶囊底本身是半透的,
|
||||
/// 投影一重就成了"漏墨"。底色相应提到 .92,少了投影仍然读得清。
|
||||
private struct FloatingCapsule<Content: View>: View {
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@ViewBuilder let content: Content
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.fill(p.surfaceDark.opacity(0.92))
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.strokeBorder(p.border, lineWidth: 1)
|
||||
)
|
||||
.txShadow(TX.Elevation.floating)
|
||||
}
|
||||
}
|
||||
|
||||
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。
|
||||
struct TerminalTitleCapsule: View {
|
||||
/// 要点(Shimo 化第三轮):① ✕/⌄ 是**实心圆**(macOS 交通灯的色语义 + 常显深色字形),
|
||||
/// 不再是描边方块;② 标题弱化成 `主机名 · cwd · 格子数` 一句话,各段逐级降灰、`·` 分隔;
|
||||
/// ③ tab 胶囊无槽无描边,选中 = accent 淡染胶囊浮起;④ 尾部 Mosh/SSH 淡染徽标
|
||||
/// —— 一眼确认传输方式(mosh 断线可存活)就在手边。
|
||||
struct TerminalTopBar: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
let onClose: () -> Void
|
||||
let onMinimize: () -> Void
|
||||
@@ -41,51 +22,84 @@ struct TerminalTitleCapsule: View {
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮,
|
||||
// 详见 windowButton。
|
||||
HStack(spacing: TX.Space.m) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。
|
||||
HStack(spacing: TX.Space.s) {
|
||||
windowButton(icon: "xmark", tint: TXWindowDot.close,
|
||||
label: "关闭会话", action: onClose)
|
||||
// 向下的 chevron 而不是双向内收箭头:后者在 9.5pt 下糊成一团认不出。
|
||||
// 向下同时对上设计里的「下滑最小化」手势语义。
|
||||
// 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。
|
||||
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
|
||||
label: "最小化到首页", action: onMinimize)
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 14)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 2)
|
||||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
|
||||
}
|
||||
titleLine
|
||||
TmuxWindowTabGroup(session: session)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 40)
|
||||
// 徽标走 overlay 而不是 HStack 尾巴:tab 组的 ScrollView 会贪掉剩余宽度,让尾巴上的
|
||||
// 兄弟布局不稳;悬浮在右端与 tab 数量无关,chip 多了从徽标下面滚过(徽标带实底)。
|
||||
.overlay(alignment: .trailing) { transportBadge }
|
||||
}
|
||||
|
||||
/// 弱化标题行:状态点 + `主机名 · cwd · 格子数`,一句话逐级降灰、`·` 分隔(Shimo 的
|
||||
/// 状态文案写法)。主机身份已由侧边栏选中行承担,这里只是就近确认。
|
||||
private var titleLine: some View {
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5)
|
||||
Text(session.displayName)
|
||||
.font(TX.Font.mono(12.5, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
.font(TX.Font.mono(12, .medium))
|
||||
.foregroundStyle(p.text3)
|
||||
.lineLimit(1)
|
||||
if !contextLine.isEmpty {
|
||||
dotSeparator
|
||||
Text(contextLine)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
// 终端格子数(设计稿 08 的「· 168×44」)。侧边栏开合会 resize ——
|
||||
// 数字经 GridBox.onGridChange → session.objectWillChange 跟着刷新。
|
||||
dotSeparator
|
||||
// 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange →
|
||||
// session.objectWillChange 跟着刷新。
|
||||
Text(gridLabel)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
}
|
||||
|
||||
private var dotSeparator: some View {
|
||||
Text("·")
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
|
||||
/// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。
|
||||
/// 传输协议徽标(Shimo 右上角同款:淡染底+同色文字、无描边)。这是 mosh/SSH 的
|
||||
/// **唯一**出口——底部状态行不再重复(协议是连接的静态属性,链路动态归左下)。
|
||||
/// 拼写与配色走 `TXTech`(mosh 小写、SSH 全大写)。连接建立前不显示(还不知道走什么)。
|
||||
@ViewBuilder private var transportBadge: some View {
|
||||
if session.isLive || session.moshEngaged {
|
||||
let t = TXTech.transport(session.moshEngaged, p)
|
||||
Text(t.text)
|
||||
.font(TX.Font.mono(11.5, .medium))
|
||||
.foregroundStyle(t.tint)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 26)
|
||||
// 双层底:tint 淡染在上、bgDeep 实底垫底(badge 悬浮在 tab 滚动区之上,
|
||||
// 不垫实底会透出滚过的 chip)。
|
||||
.background(t.tint.opacity(0.14), in: Capsule())
|
||||
.background(p.bgDeep, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。
|
||||
/// 无 tmux(没有 tab 组)时退回连接状态文案。
|
||||
private var contextLine: String {
|
||||
guard let t = session.tmuxSummary else {
|
||||
return session.isLive ? "" : session.phaseText
|
||||
}
|
||||
var parts: [String] = []
|
||||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||||
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
|
||||
return parts.joined(separator: " · ")
|
||||
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
|
||||
}
|
||||
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法)。
|
||||
static func abbreviate(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
|
||||
@@ -99,31 +113,27 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
|
||||
private var gridLabel: String {
|
||||
// tmux 模式读 controller 的客户端格子(raw 的 screenGrid 随 RawTerminalView 卸载而冻结,
|
||||
// 侧边栏开合后会显示旧值);raw 模式仍读 surface 实报的 screenGrid。
|
||||
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
let g = session.screenGrid.value
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
|
||||
/// 关闭 / 最小化按钮。
|
||||
///
|
||||
/// 原来是 macOS 交通灯式的 13pt 纯色圆点,三处不合适:① 纯色块**不说明自己是干什么的** ——
|
||||
/// mac 上靠 hover 才浮出字形,触屏没有 hover,于是永远只是两个色点;② 13pt 命中目标在
|
||||
/// 触屏上太小,而"关闭"误触代价很高;③ 圆点是 mac 窗口的语汇,与本 app 通篇的圆角矩形
|
||||
/// 控件语汇不搭。
|
||||
///
|
||||
/// 现在:22pt 圆角方块 + **常显字形**(`✕` 关闭 / `⌄` 收下去 = 缩回首页卡片),颜色语义保留
|
||||
/// (红=关闭、琥珀=最小化),底色只上 14% 不铺满 —— 与徽标/控件同一套形状语言,命中 26×34。
|
||||
/// 关闭 / 最小化按钮:**实心圆**(Shimo 同款)+ 常显深色字形(触屏没有 hover,
|
||||
/// 纯色点说明不了自己)。颜色语义保留(红=关闭、琥珀=最小化),饱和圆面自己就是容器,
|
||||
/// 不再描边画框。
|
||||
private func windowButton(icon: String, tint: Color, label: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 9.5, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 26, height: 34)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Color.black.opacity(0.55))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(tint, in: Circle())
|
||||
.frame(width: 30, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
@@ -131,84 +141,101 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。
|
||||
/// tmux window 的 **tab 组**:无槽无描边的胶囊排(Shimo 化第三轮 —— 旧「描边槽 + 描边
|
||||
/// chip」被用户裁决砍掉:暗底上层层画框显脏)。
|
||||
///
|
||||
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格
|
||||
/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。
|
||||
struct TerminalStatusCapsule: View {
|
||||
/// 「组」的语义靠**相邻**表达:选中 chip 是唯一的 accent 淡染胶囊(浮起),未选 chip 纯文字
|
||||
/// 贴底;尾部 `+` 新建 window(浏览器 tab 组惯例)。
|
||||
/// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。
|
||||
struct TmuxWindowTabGroup: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
|
||||
var body: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
WindowTabGroupRow(controller: tmux)
|
||||
} else if let summary = session.tmuxSummary {
|
||||
TabGroupSlot {
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowTabChipBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty
|
||||
? summary.currentTitle : "window \(i)",
|
||||
isSelected: i == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。
|
||||
private struct WindowTabGroupRow: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
|
||||
var body: some View {
|
||||
// 无 `+` 新建入口(第一版减法,用户裁决):window 的增删都交给 tmux 自己
|
||||
// (prefix+c / prefix+&),tab 只做**切换与反映**。
|
||||
TabGroupSlot {
|
||||
ForEach(controller.windows) { win in
|
||||
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
|
||||
controller.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tab 组的容器:**无槽体**,chip 靠相邻成组(Shimo 语言:分组靠留白,不靠画框)。
|
||||
/// 条目多时横向滚动。
|
||||
private struct TabGroupSlot<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: TX.Space.hairline) { content }
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 window chip(title/index 是 window 自己的 @Published,须直接订阅才会刷新)。
|
||||
private struct WindowTabChip: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel("切换到 window \(window.index)")
|
||||
}
|
||||
}
|
||||
|
||||
/// chip 的视觉本体(真实 window 与占位共用):`编号 标题` 胶囊。选中 = accent 淡染胶囊
|
||||
/// 浮起(无描边,Shimo 的徽标语言),未选 = 纯文字贴底。
|
||||
private struct WindowTabChipBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(transportText, color: transportColor, dot: false)
|
||||
separator
|
||||
cell(tmuxText, color: p.text2, dot: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 16)
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
}
|
||||
|
||||
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
if dot { TXStatusDot(color: color, size: 6) }
|
||||
Text(text)
|
||||
.font(TX.Font.mono(12, .medium))
|
||||
.foregroundStyle(color)
|
||||
Text(index)
|
||||
.font(TX.Font.mono(10.5, .semibold))
|
||||
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
|
||||
Text(title)
|
||||
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? TXAccent.textBright : p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第一格:网络可达性(tsnet 出口 / 直连)
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "direct"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting: "tsnet"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:传输层(mosh / SSH)
|
||||
private var transportText: String {
|
||||
if case .reconnecting(let n) = session.link {
|
||||
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
|
||||
}
|
||||
return session.moshEngaged ? "mosh" : "ssh"
|
||||
}
|
||||
|
||||
private var transportColor: Color {
|
||||
if case .reconnecting = session.link { return p.reconnecting }
|
||||
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
|
||||
return p.text2
|
||||
}
|
||||
|
||||
// 第三格:会话复用层
|
||||
private var tmuxText: String {
|
||||
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : "—"
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 28)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear, in: Capsule())
|
||||
.contentShape(Rectangle())
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,153 +345,91 @@ 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 }
|
||||
// (「未检测到 tmux」横幅与「安装并启用 tmux」引导弹层已按用户裁决删除:无 tmux 静默走
|
||||
// 原生窗口,状态只在底部状态行一句话,不打扰。)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 舞台底部动作条。
|
||||
/// 舞台底部**状态行**(动作按钮已按第一版减法裁掉:window 管理交给 tmux 自己,
|
||||
/// 回首页走侧边栏 / 顶带 ⌄ 最小化)。
|
||||
///
|
||||
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「竖分屏 / 新 window / 回首页」
|
||||
/// 只写成 `⌘D` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
|
||||
/// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。
|
||||
/// 左下是**网络/会话**状态的唯一出口:`[dot 链路] | tmux 概览`。传输协议(mosh/SSH)
|
||||
/// **不在这里**——它是连接的静态属性,由顶带右上的协议徽标独占(原先两处都写 mosh,
|
||||
/// 用户裁决去重);重连计数并入第一格(重连是链路层的事)。
|
||||
/// **只染出问题的那层**:链路断/重连 → 第一格转琥珀/灰;tmux 缺失 → 第二格写
|
||||
/// 「无 tmux · 客户端窗口」而非报错。主机名不再重复出现(顶带标题已有)。
|
||||
///
|
||||
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮
|
||||
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚
|
||||
/// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。)
|
||||
struct TerminalActionBar: View {
|
||||
/// 左侧上下文说明(会话/链路状态一句话)。
|
||||
let context: String
|
||||
let actions: [Action]
|
||||
@ObservedObject var session: TerminalSession
|
||||
let safeBottom: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.horizontalSizeClass) private var hSize
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
struct Action: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
/// 不可用时灰显但仍在(如非 tmux 会话不能分屏)——不隐藏,免得布局跳。
|
||||
var enabled: Bool = true
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
/// 窄屏只留前两个:四个在 iPhone 上放不下,会折行把 34pt 的带撑破。
|
||||
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Text(context)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
statusLine
|
||||
Spacer(minLength: TX.Space.s)
|
||||
ForEach(shown) { action in
|
||||
Button(action: action.run) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: action.icon)
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
Text(action.title)
|
||||
.font(TX.Font.mono(11.5, .medium))
|
||||
}
|
||||
.foregroundStyle(action.enabled ? p.text2 : p.textFaint)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.frame(height: 26)
|
||||
.background(action.enabled ? p.surface : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.badge)
|
||||
.strokeBorder(action.enabled ? p.border : p.divider, lineWidth: 1)
|
||||
)
|
||||
// 动作条永远**单行**:宁可少放一个也不许折行(折行会溢出底部带)。
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.disabled(!action.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: TX.Layout.footBand(safeBottom))
|
||||
.animation(TX.Motion.standard, value: context)
|
||||
.animation(TX.Motion.standard, value: contextText)
|
||||
}
|
||||
|
||||
// MARK: - 状态行
|
||||
|
||||
private var statusLine: some View {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(contextText, color: p.text3, dot: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 12)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
}
|
||||
|
||||
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
if dot { TXStatusDot(color: color, size: 6) }
|
||||
Text(text)
|
||||
.font(TX.Font.mono(11.5, .medium))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第一格:链路(tsnet 出口 / 直连 / 重连计数)。拼写按 TXTech 规范(tsnet 官方小写)。
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "直连"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting(let n): "重连中 · 第 \(n) 次"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:会话复用层概览(windows 数保留)。
|
||||
private var contextText: String {
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : session.phaseText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||||
// 设计稿 `3c 关闭确认`。(`3b 新建分屏菜单` 已随「放弃移动端分屏」移除。)
|
||||
//
|
||||
// 语义分工(务必别混):
|
||||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||||
@@ -137,148 +137,3 @@ struct CloseConfirmDialog: View {
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 新建分屏菜单(3b)
|
||||
|
||||
/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」:
|
||||
/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。
|
||||
///
|
||||
/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。
|
||||
struct SplitMenuView: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
/// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D)
|
||||
let vertical: Bool
|
||||
let onPick: (SplitTarget) -> Void
|
||||
let onCancel: () -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
enum SplitTarget {
|
||||
case currentSession
|
||||
case otherSession(String)
|
||||
case newSession
|
||||
/// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。
|
||||
case nativeWindow
|
||||
}
|
||||
|
||||
/// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端
|
||||
/// attach 着其它会话时会把它们错误地排除掉。)
|
||||
private var detached: [TmuxSessionInfo] {
|
||||
let current = session.tmuxSummary?.name ?? ""
|
||||
return session.tmuxSessionList.filter { $0.name != current }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TXModalScrim(onDismiss: onCancel) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Rectangle().fill(p.divider).frame(height: 1)
|
||||
if session.tmuxSummary != nil {
|
||||
TXSectionLabel(text: "tmux 会话")
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.padding(.top, TX.Space.s)
|
||||
.padding(.bottom, 2)
|
||||
row(title: currentName, detail: currentDetail, dot: p.online,
|
||||
tag: "当前", selected: true) { onPick(.currentSession) }
|
||||
ForEach(detached) { s in
|
||||
// detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。
|
||||
row(title: s.name,
|
||||
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
|
||||
dot: p.online,
|
||||
tag: s.lastActivity.map { SessionCard.relative($0) }) {
|
||||
onPick(.otherSession(s.name))
|
||||
}
|
||||
}
|
||||
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
|
||||
onPick(.newSession)
|
||||
}
|
||||
Rectangle().fill(p.divider).frame(height: 1)
|
||||
.padding(.vertical, TX.Space.xs)
|
||||
}
|
||||
// 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。
|
||||
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
|
||||
dot: nil, tag: nil, disabled: true) {}
|
||||
}
|
||||
.padding(.bottom, TX.Space.xs)
|
||||
.frame(width: 360)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
|
||||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
||||
)
|
||||
.txShadow(TX.Elevation.dialog)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text(vertical ? "竖分屏到…" : "横分屏到…")
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Spacer()
|
||||
Text(session.displayName)
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(TXAccent.mid)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
private var currentName: String {
|
||||
guard let t = session.tmuxSummary else { return session.displayName }
|
||||
return t.name.isEmpty ? "当前会话" : t.name
|
||||
}
|
||||
|
||||
private var currentDetail: String {
|
||||
guard let t = session.tmuxSummary else { return "" }
|
||||
var parts: [String] = []
|
||||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||||
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
|
||||
if t.panes > 0 { parts.append("\(t.panes) panes") }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。
|
||||
private func row(title: String, detail: String?, dot: Color?, tag: String?,
|
||||
selected: Bool = false, disabled: Bool = false,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
ZStack {
|
||||
if let dot { TXStatusDot(color: dot, size: 7) }
|
||||
}
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(disabled ? p.textWeak : p.text)
|
||||
if let detail, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: TX.Space.xs)
|
||||
if let tag {
|
||||
Text(tag)
|
||||
.font(TX.Font.mono(10.5, .medium))
|
||||
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 40)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -225,14 +228,6 @@ final class TerminalSession: ObservableObject, Identifiable {
|
||||
|
||||
/// 假会话注入的 tmux 概览(真实会话走 `tmuxSummary` 的派生分支)。
|
||||
private var injectedTmuxSummary: TmuxSummary?
|
||||
/// 假会话注入的 tmux 会话列表(分屏菜单的 detached 行;真实会话走 controller.sessions)。
|
||||
private(set) var injectedTmuxSessions: [TmuxSessionInfo] = []
|
||||
|
||||
/// 分屏菜单的候选会话:真实会话用 controller 的 list-sessions 结果,假会话用注入值。
|
||||
var tmuxSessionList: [TmuxSessionInfo] {
|
||||
if let c = tmuxController, !c.sessions.isEmpty { return c.sessions }
|
||||
return injectedTmuxSessions
|
||||
}
|
||||
|
||||
/// tmux 概览:假会话用注入值,真实会话从 controller 现算。
|
||||
var tmuxSummary: TmuxSummary? {
|
||||
@@ -579,10 +574,9 @@ final class TerminalSession: ObservableObject, Identifiable {
|
||||
/// 只为让首页会话卡/轨道头像/状态徽标有内容可截图对拍(见 UIPreviewFixture)。
|
||||
/// 走 `gate` 而非 `session.receive`,故 surface 未 attach 时会先缓冲、就绪后 flush。
|
||||
func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool,
|
||||
tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) {
|
||||
tmux: TmuxSummary? = nil) {
|
||||
isSyntheticFixture = true
|
||||
injectedTmuxSummary = tmux
|
||||
injectedTmuxSessions = tmuxSessions
|
||||
self.host = host
|
||||
connectionTitle = host.name
|
||||
isLive = true
|
||||
@@ -862,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) {
|
||||
|
||||
@@ -289,12 +289,8 @@ enum TX {
|
||||
static let sidebarWidth: CGFloat = 288
|
||||
/// pane 之间的缝
|
||||
static let paneGap: CGFloat = 2
|
||||
/// 拖拽热区
|
||||
static let paneDragHit: CGFloat = 12
|
||||
/// 触控命中区下限
|
||||
static let minTouch: CGFloat = 44
|
||||
/// 终端主 pane 顶部给浮起胶囊预留
|
||||
static let capsuleInset: CGFloat = 86
|
||||
/// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条
|
||||
/// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区
|
||||
/// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。
|
||||
|
||||
@@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
|
||||
init(id: TmuxPaneID, cols: Int, rows: Int,
|
||||
onInput: @escaping @Sendable (Data) -> Void,
|
||||
onMetrics: @escaping @Sendable (Int, Int) -> Void) {
|
||||
onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
|
||||
self.id = id
|
||||
self.grid = (cols, rows)
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
|
||||
// tmux 模式:surface resize 只回报 cell 像素供换算,绝不反向驱动 tmux(防反馈环)。
|
||||
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
// tmux 模式:surface resize 上报**真实格子 + cell 像素**(cols/rows/cellW/cellH)。
|
||||
// controller 只在「单 pane window」时把它当客户端尺寸的权威(见 paneViewportChanged),
|
||||
// 多 pane 下仅刷新 cell 缓存 —— 依旧不构成反馈环。
|
||||
resize: { vp in onViewport(Int(vp.columns), Int(vp.rows),
|
||||
Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
@@ -54,16 +57,6 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。
|
||||
struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let windows: Int
|
||||
/// 是否已被某客户端 attach(当前会话即 attached 的那个)。
|
||||
let isAttached: Bool
|
||||
let lastActivity: Date?
|
||||
}
|
||||
|
||||
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
|
||||
/// 尺寸走"路线 b":app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取
|
||||
/// layout rect(%output 按 tmux pane 宽高排版,二者一致才不换行/清屏错乱)。
|
||||
@@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
final class TmuxController: ObservableObject {
|
||||
@Published private(set) var windows: [TmuxWindow] = []
|
||||
@Published var activeWindowID: TmuxWindowID?
|
||||
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
|
||||
@Published private(set) var sessions: [TmuxSessionInfo] = []
|
||||
|
||||
private let parser = TmuxControlParser()
|
||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||
@@ -82,6 +73,8 @@ final class TmuxController: ObservableObject {
|
||||
var onExit: (() -> Void)?
|
||||
|
||||
private var clientCols = 80, clientRows = 24
|
||||
/// 客户端格子对外快照(顶栏胶囊显示用 —— tmux 模式下 raw 的 screenGrid 已随 RawTerminalView 卸载冻结)。
|
||||
@Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0)
|
||||
private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子)
|
||||
private var lastSentCols = 0, lastSentRows = 0
|
||||
private var resizeDebounce: Task<Void, Never>?
|
||||
@@ -89,12 +82,11 @@ final class TmuxController: ObservableObject {
|
||||
// control-mode 命令应答 FIFO 匹配。
|
||||
private var attachAcked = false
|
||||
private enum PendingKind: CustomStringConvertible {
|
||||
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
|
||||
case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ignore: "ignore"
|
||||
case .listWindows: "listWindows"
|
||||
case .listSessions: "listSessions"
|
||||
case .currentSession: "currentSession"
|
||||
case .capturePane(let p): "capturePane(%\(p.raw))"
|
||||
}
|
||||
@@ -122,8 +114,9 @@ final class TmuxController: ObservableObject {
|
||||
private func kickoffIfReady() {
|
||||
guard attachAcked, containerKnown, !kickoffDone else { return }
|
||||
kickoffDone = true
|
||||
lastSentCols = clientCols; lastSentRows = clientRows // 后续实测同值时不再重发
|
||||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface 实测格子(非像素换算)
|
||||
pending.append(.ignore)
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
|
||||
@@ -132,30 +125,64 @@ final class TmuxController: ObservableObject {
|
||||
// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。
|
||||
sendCommand("display-message -p \"#{session_name}\"")
|
||||
pending.append(.currentSession)
|
||||
listSessions() // 分屏菜单要列全部会话
|
||||
}
|
||||
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
||||
func setClientSize(cols: Int, rows: Int) {
|
||||
clientCols = max(1, cols); clientRows = max(1, rows)
|
||||
clientGrid = (clientCols, clientRows)
|
||||
}
|
||||
|
||||
/// cell 设备像素(来自 raw 终端或 pane surface metrics)。
|
||||
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
|
||||
/// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。
|
||||
func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) }
|
||||
|
||||
/// pane surface 的真实格子上报 —— **客户端尺寸的权威来源**。
|
||||
///
|
||||
/// 像素除法(containerResized)复刻不了 ghostty 内部的取整/内边距:真机与模拟器实测
|
||||
/// 「容器 px ÷ 整数 cell px」比 surface 真实格子**偏大 1 行/列**(如算得 164×51、真实 163×50)。
|
||||
/// 偏大意味着远端按更宽的行排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,
|
||||
/// 全屏 TUI(Claude Code 等)重绘几轮就积累成行首碎片(真机花屏根因)。
|
||||
/// 单 pane window 的 pane 帧恒等于容器帧,其 ghostty 实测格子就是客户端应有的格子 ——
|
||||
/// 用它发 refresh-client。帧尺寸只由容器决定、不受 tmux 布局影响,故没有反馈环。
|
||||
func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||
setCellPixels(w: cellW, h: cellH)
|
||||
guard kickoffDone, cols > 0, rows > 0 else { return }
|
||||
// 多 pane window 的 pane 帧只是容器的比例切片,格子代表不了客户端整体 → 只刷 cell 缓存。
|
||||
guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return }
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, rows)
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 120_000_000) // 合并侧边栏动画期间的连续变化
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)")
|
||||
self.sendCommand("refresh-client -C \(cols)x\(rows)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。
|
||||
/// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。
|
||||
/// 尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",**仅作兜底**:有单 pane window 在场时
|
||||
/// 让位给 paneViewportChanged 的实测格子(像素除法会偏大 1 行/列,见其注释)。
|
||||
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
|
||||
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
|
||||
containerKnown = true
|
||||
if !kickoffDone {
|
||||
// kickoff 初值不用像素换算覆盖 —— setClientSize 已带 raw surface 的真实格子
|
||||
// (attach 前 RawTerminalView 与 TmuxStage 同槽同尺寸,ghostty 实测值可直接沿用)。
|
||||
NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)")
|
||||
kickoffIfReady()
|
||||
return
|
||||
}
|
||||
if windows.contains(where: { $0.panes.count == 1 }) { return } // 实测路径接管
|
||||
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
|
||||
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, rows)
|
||||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
|
||||
// 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。
|
||||
containerKnown = true
|
||||
if !kickoffDone { kickoffIfReady(); return }
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
||||
@@ -256,7 +283,9 @@ final class TmuxController: ObservableObject {
|
||||
let surface = TmuxPaneSurface(
|
||||
id: p, cols: cols, rows: rows,
|
||||
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
|
||||
onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } }
|
||||
onViewport: { [weak self] c, r, cw, ch in
|
||||
Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) }
|
||||
}
|
||||
)
|
||||
surfaceByPane[p] = surface
|
||||
win.panes[p] = surface
|
||||
@@ -293,22 +322,6 @@ final class TmuxController: ObservableObject {
|
||||
if parts[2] == "1" { activeWindowID = w }
|
||||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||||
}
|
||||
case .listSessions:
|
||||
sessions = r.lines.compactMap { line in
|
||||
let f = line.components(separatedBy: Self.fieldSep)
|
||||
guard f.count >= 3 else { return nil }
|
||||
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
|
||||
return TmuxSessionInfo(name: f[0],
|
||||
windows: Int(f[1]) ?? 1,
|
||||
isAttached: f[2] == "1",
|
||||
lastActivity: activity)
|
||||
}
|
||||
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
NSLog("TMUXDBG currentSession=\(name)")
|
||||
}
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
@@ -376,10 +389,12 @@ final class TmuxController: ObservableObject {
|
||||
sendCommand("select-window -t @\(w.raw)")
|
||||
}
|
||||
|
||||
func newWindow() { sendCommand("new-window") }
|
||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||
// MARK: - 关闭(UI-4)
|
||||
|
||||
// MARK: - 关闭 / 分屏 / resize(UI-4)
|
||||
// 注:window 的增删(new-window / kill-window)与 pane resize 已按「第一版减法」移除
|
||||
// —— window/pane 管理完全交给 tmux 自己(prefix+c / prefix+& / prefix+方向键),app 只做
|
||||
// 切换与被动渲染;app 内主动分屏(split-window / join-pane)更早已随「放弃移动端分屏」移除。
|
||||
// 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。
|
||||
|
||||
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
|
||||
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
|
||||
@@ -388,38 +403,6 @@ final class TmuxController: ObservableObject {
|
||||
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
|
||||
func killSession() { sendCommand("kill-session") }
|
||||
|
||||
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
|
||||
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
|
||||
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
|
||||
func splitWindow(vertical: Bool, joinSession: String? = nil) {
|
||||
if let s = joinSession {
|
||||
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
|
||||
} else {
|
||||
sendCommand("split-window \(vertical ? "-h" : "-v")")
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
|
||||
func splitIntoNewSession(vertical: Bool, name: String) {
|
||||
sendCommand("new-session -d -s \(name)")
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
|
||||
}
|
||||
|
||||
/// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。
|
||||
/// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。
|
||||
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
|
||||
if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") }
|
||||
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
|
||||
}
|
||||
|
||||
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
|
||||
func listSessions() {
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
|
||||
pending.append(.listSessions)
|
||||
}
|
||||
|
||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||
|
||||
|
||||
@@ -150,18 +150,6 @@ enum UIPreviewFixture {
|
||||
]),
|
||||
]
|
||||
|
||||
/// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。
|
||||
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
|
||||
// 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。
|
||||
[
|
||||
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
|
||||
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
|
||||
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
|
||||
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
|
||||
lastActivity: Date(timeIntervalSinceNow: -86400)),
|
||||
]
|
||||
}
|
||||
|
||||
/// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。
|
||||
static func hostFromLaunchArgs() -> SavedHost? {
|
||||
let d = UserDefaults.standard
|
||||
@@ -186,10 +174,10 @@ enum UIPreviewFixture {
|
||||
/// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。
|
||||
/// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。
|
||||
///
|
||||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h`
|
||||
/// · `split-now/v|h`(直接分屏) · `resize/<cols>`(resize-pane)
|
||||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关)
|
||||
/// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器)
|
||||
/// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin`
|
||||
/// (`resize/<cols>` 已随「pane 拖拽调宽」一起删除 —— pane 尺寸交给 tmux 自己。)
|
||||
@MainActor
|
||||
static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool {
|
||||
switch path.first {
|
||||
@@ -204,16 +192,8 @@ enum UIPreviewFixture {
|
||||
case "close-now":
|
||||
// 跳过确认直接关(回归脚本用)。
|
||||
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
|
||||
case "split":
|
||||
guard router.route.sessionID != nil else { return false }
|
||||
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
|
||||
case "dismiss":
|
||||
router.dismissModal()
|
||||
case "split-now":
|
||||
// 跳过菜单直接对当前会话分屏(验证 split-window 链路)。
|
||||
guard let id = router.route.sessionID, let s = manager.session(id),
|
||||
let tmux = s.tmuxController else { return false }
|
||||
tmux.splitWindow(vertical: path.dropFirst().first != "h")
|
||||
case "pick":
|
||||
// 会话选择器:attach 指定会话(`ui/pick/<name>`)。此时通常还在首页,故优先取待选择的会话。
|
||||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),
|
||||
@@ -228,18 +208,10 @@ enum UIPreviewFixture {
|
||||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
|
||||
else { return false }
|
||||
s.useNativeTerminal()
|
||||
case "resize":
|
||||
// 验证 resize-pane 链路(拖拽手势本身是标准 DragGesture):`ui/resize/<cols>`。
|
||||
guard let id = router.route.sessionID, let s = manager.session(id),
|
||||
let tmux = s.tmuxController,
|
||||
let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }),
|
||||
let pane = win.activePane,
|
||||
let cols = path.dropFirst().first.flatMap(Int.init) else { return false }
|
||||
tmux.resizePane(pane, cols: cols)
|
||||
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 同义(兼容旧脚本)。
|
||||
|
||||
@@ -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 流程补测。
|
||||
@@ -162,7 +163,7 @@
|
||||
| UI-1 | 设计令牌地基(Nocturne chrome + blurple + JetBrains Mono) | ✅ 完成 |
|
||||
| UI-2 | 导航与多会话状态模型(AppRouter/SessionManager/SessionCanvas) | ✅ 完成 |
|
||||
| UI-3 | 终端主界面:56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | ✅ 完成(pane 缝拖拽待做) |
|
||||
| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(pane 拖拽待真机 tmux 验证) |
|
||||
| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(分屏功能后被用户裁决整体移除,见 12.3k) |
|
||||
| UI-5 | 设置·外观 + 键盘体系(GCKeyboard 检测 + 全套 ⌘ 快捷键) | 待做 |
|
||||
| UI-6 | 竖屏参数化 + iPhone 首版 | 待做 |
|
||||
|
||||
@@ -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 **内容原样**。
|
||||
@@ -359,6 +360,38 @@
|
||||
- **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox` 加 `onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
|
||||
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pt(easeOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。
|
||||
|
||||
### 12.3k 放弃移动端分屏 + tmux window Tab 上移 + 状态出口合并(用户裁决,2026-07-26)
|
||||
|
||||
用户判词:全面放弃移动端的分屏**操作**(非核心功能);tmux 的切换从侧边栏移到顶部作 Tab,**活动会话(主机连接)的切换仍在侧边栏**;右上状态文字移到左下并与既有文字**合并**(原两处信息重复)。
|
||||
(第一版误把「会话列表」理解成主机连接列表上移为 Tab,用户纠正 + AskUser 确认:Tab = **tmux window**(与 tmux -CC「window 映射为原生 tab」惯例一致),非 tmux session 切换——后者需 switch-client + 窗口全量重建,未做。)
|
||||
|
||||
**1. 删分屏(入口 + 逻辑)**:
|
||||
- UI 入口:底部动作条「竖分屏」按钮、分屏菜单 `SplitMenuView`(12.3d 的 3b 卡片整个删除)、`AppModal.splitMenu`。
|
||||
- 逻辑:`TmuxController.splitWindow/splitIntoNewSession`、`list-sessions` 管线(`TmuxSessionInfo`/`sessions`/`listSessions()`/`PendingKind.listSessions`——只服务分屏菜单)、`TerminalSession.tmuxSessionList`/`injectedTmuxSessions`、fixture 的 `tmuxSessions()`、无头 URL `ui/split`/`ui/split-now`。
|
||||
- **保留**:多 pane 的**被动渲染**与 `PaneSplitter` 拖拽/`resizePane`(别的客户端建好的分屏 attach 进来仍须按 layout 正确显示)、`ui/resize/<cols>`。
|
||||
- 顺带修掉 `TmuxController` 响应 switch 里重复的 `case .currentSession`(死代码)。
|
||||
|
||||
**2. tmux window Tab(`TmuxWindowTabsBar`,TerminalChrome.swift)**:顶带 = 左标题胶囊 + window Tab 条(右上状态胶囊撤销)。一个 window 一个浮起胶囊 tab(编号徽标 + 标题),点击 `selectWindow`(链路复用旧侧边栏 window 行);当前 window accent 高亮;controller 未 attach 但有 `tmuxSummary` 时按占位渲染(fixture/接回前),原生窗口不显示。三层嵌套订阅各自拆视图(`WindowTabsRow` 订阅 controller、`WindowTab` 订阅 window——title/index 是 window 自己的 @Published)。侧边栏 tmux 段(`tmuxDivider`/`tmuxLabelRow`/`tmuxRows`/`SidebarWindowRow`/`WindowRowBody`/「新建 window」行)删除,「新建 window」入口保留在底部动作条;侧边栏 = 首页 / **活动会话(保留,主机连接切换)** / 连接新主机 / tailnet / 设置,不再要 `session` 参数。
|
||||
**3. 状态出口合并(`TerminalActionBar.statusLine`)**:原右上三格分层徽标(`tsnet|mosh|tmux`)与原左下上下文文字合并为左下**唯一**状态行:`[dot 链路] | 传输(重连计数) | 会话概览(tmux 名 · N windows / 原生窗口 / 无 tmux / phaseText)`。主机名不再出现(标题胶囊与当前 tab 已有)。「只染出问题的那层”规则原样保留。
|
||||
- 验证:fixture 三会话截图对拍(window tab 高亮/占位 tab/合并状态行/侧边栏两态排版含活动会话段)+ `ui/session/2` 切换 + 开合 resize 格子数跟随(164×50⇄135×50)。真机 tmux 下点 tab 切 window 待验证(链路与旧侧边栏 window 行相同)。
|
||||
|
||||
### 12.3l 顶带落地重设计:按钮分离 / 标题弱化 / tab 组(用户定稿,2026-07-26)
|
||||
|
||||
用户判词:按钮、title、tab 可以在终端**外**区域;按钮分离出来单独悬浮;标题弱化;tab 要有「组」的概念。
|
||||
|
||||
- **浮起胶囊废除,顶带落地为真实 chrome 行**(`TerminalTopBar` 替换 `TerminalTitleCapsule`+`FloatingCapsule`):`stage` 从 ZStack overlay 改回 VStack 首行,终端 pane 从带下方开始 —— 86pt 预留带(`TX.Layout.capsuleInset`,已删令牌)与「胶囊遮内容」问题一并消失。pane 顶边现在朝 chrome:topLeading 收圆角、顶描边恢复(不再 `top:-1` 推出屏外);右边仍贴屏幕物理边缘不收角。pane 内容留 `TX.Space.s` 呼吸位。
|
||||
- **顶带层级(一眼一个主角)**:`[✕ ⌄ 独立按钮组] · 弱化标题 · [tab 组]`。按钮升到 28pt 圆角方块自成一簇(危险操作与身份信息不共容器);标题降为纯文本行(状态点 5pt + 主机名 mono12 text2 + cwd textWeak + 格子数 textFaint),**当前 window title 从标题里删掉** —— 它已由 tab 组选中 chip 承担,写两遍是重复;tab 组是顶带唯一带容器的元素。
|
||||
- **tab 组(`TmuxWindowTabGroup`)**:surfaceDark 圆角槽(radius 11)内衬 3pt,window 是槽内段式 chip(选中 = selectedBg+描边浮起,未选 = 透明贴槽底),尾部 `+` 新建 window。**坑:槽体背景必须挂在 ScrollView 的内容层** —— 挂 ScrollView 自身就是一条拖到屏幕右边的空槽(ScrollView 恒占满剩余宽度)。占位态(controller 未 attach 但有 tmuxSummary)共用 chip 本体;原生窗口不渲染 tab 组。
|
||||
- 验证:fixture 截图 ×3(tmux 态顶带 / 原生窗口态无 tab 组 / 展开侧边栏组合态),标题去重与槽体紧贴成组均确认。
|
||||
|
||||
### 12.3m 换行错乱根因修复:客户端格子的权威改为 ghostty 实测(真机报障,2026-07-26)
|
||||
|
||||
用户报障:无论侧边栏收起/展开,终端(Claude Code 等全屏 TUI)换行错乱、行首冒碎片,竞品完美。
|
||||
|
||||
- **根因**(模拟器连本机 sshd 复现实锤):`containerResized` 用「容器px ÷ 整数cell px」算 tmux 客户端格子,复刻不了 ghostty 内部取整/内边距 —— 实测算得 **164×51**、ghostty 真实 **163×50**(列差来自 `TmuxPaneView` 的 1pt padding 圈,行差来自像素除法本身)。远端按 164 列排版、surface 按 163 列自动换行 → 每条满宽行掉字符,重绘积累成花屏。
|
||||
- **修复**(`TmuxController` + `ContentView` + `TerminalChrome`):① pane resize 回调从「只报 cell 像素」升级为上报**完整实测格子**(`paneViewportChanged`);② **单 pane window 的 pane 帧==容器帧 → 它的实测格子就是客户端格子**,据此发 `refresh-client -C`(帧只由容器决定,无反馈环);③ kickoff 初值改用 raw surface 实测格子(`setClientSize` 不再被像素换算覆盖);④ 单 pane 时去掉 pane 的 1pt padding 圈(`solo`);⑤ 像素除法只兜底「全是多 pane window」的场景;⑥ 顶栏格子数 tmux 模式改读 `TmuxController.clientGrid`(`screenGrid` 随 RawTerminalView 卸载已冻结)。
|
||||
- **验证**(模拟器 → 本机 sshd + tmux 3.7b 端到端):`tput cols` 宽字符标尺恰好一行不换行;侧边栏收起 164×50 ⇄ 展开 135×50 双态各验证;`top` 全屏重绘干净;顶栏胶囊数字随开合正确跳变。回归法已写入 CLAUDE.md(模拟器 `-txPubkeyAuth 1` 连 127.0.0.1,公钥临时入 authorized_keys)。
|
||||
|
||||
### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做)
|
||||
`mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。
|
||||
</content>
|
||||
|
||||
@@ -120,11 +120,12 @@
|
||||
}
|
||||
|
||||
core.setFocus(true)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
// 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()
|
||||
}
|
||||
#endif
|
||||
stopMomentumScrolling()
|
||||
|
||||
let button = pointerButton(from: event)
|
||||
|
||||
Reference in New Issue
Block a user