Compare commits

...

4 Commits

Author SHA1 Message Date
kid
4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00
kid
8da725114f feat: 顶带 Shimo 化重设计 + 第一版功能减法
- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标
- 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽,
  window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize)
- 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占
- 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范
- CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:52:19 +08:00
kid
e59051a6de docs: 修正 CLAUDE.md 中过期的 git remote 陈述
原文写「无 remote」已过期(origin 已配置),且硬编码提交数易再次腐化,
改为提示现查 git status 而非凭记忆判断。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 15:59:47 +08:00
kid
5d1abdc7ac feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法
  偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测
  格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏
  格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证)
- 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 +
  TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容
- 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部
  tab,侧边栏改列活动会话;底部动作条合并状态行
- Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需,
  缺失时 socket 静默超时)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 11:11:20 +08:00
19 changed files with 786 additions and 1368 deletions

View File

@@ -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 install app --device $IPAD "$APP"
xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx
``` ```
自动签名已配好Team J75A6S5938。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 ReleaseDebug 的 SwiftUI 开销会让动效发闷)。 自动签名已配好Team J75A6S5938。**LAN 直连需要「本地网络」权限**Info.plist 已带 `NSLocalNetworkUsageDescription`(没有它系统可能不弹框直接静默拒绝);重装后首次连 LAN 主机会弹授权框,被拒的表现是 **socket 纯超时(约 12s别的 app 正常**——去 设置→隐私与安全性→本地网络 打开开关2026-07-26 真机踩坑)。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 ReleaseDebug 的 SwiftUI 开销会让动效发闷)。
## 无头验证(本环境无 GUI用 simctl 截图取证) ## 无头验证(本环境无 GUI用 simctl 截图取证)
iPad Pro 13(M5) 模拟器 UDID`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。 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 不符 → 弹告警) - **M4 host key mismatch 测试**`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
**UI 改造期无头验证(推荐走脚本)**`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。 **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` 互斥。 - **`-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` 目测分不出,必须采样像素。 - **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E``#11121C` 目测分不出,必须采样像素。
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView``router.route` 判断。 - **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView``router.route` 判断。
- **`-txSlowMotion 1`**:所有动效 ×12DEBUG`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到**三个不同的中间值**;硬切时三张一模一样。 - **`-txSlowMotion 1`**:所有动效 ×12DEBUG`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/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。 - `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` - 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)。 - `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` - `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` 当成了当前会话)。 - **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。 - **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**`tmuxSummary` 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。 - **`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 - **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 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 → 降级客户端窗口 + 行内安装引导横幅(设计 2c**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。 - **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 宽高排版,二者不一致必换行/清屏错乱)pane surface 的 resize 回调**只断言不反向驱动 tmux**(防反馈环)。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。 - **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **客户端格子数的权威 = ghostty 实测,绝不是像素除法**2026-07-26 真机 Claude Code 花屏根因):`容器px ÷ 整数cell px` 复刻不了 ghostty 内部的取整/内边距,实测**偏大 1 行/列**(算得 164×51真实 163×50→ 远端按更宽排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,全屏 TUI 重绘几轮积累成行首碎片。修法:① kickoff 用 raw surface 实测格子(`setClientSize(screenGrid)`,不再被像素换算覆盖);② **单 pane window 的 pane 帧==容器帧,其 resize 上报的实测格子就是客户端格子**`paneViewportChanged``refresh-client -C`(帧只由容器决定、与 tmux 布局无关,故无反馈环);③ 单 pane 时去掉 `TmuxPaneView` 的 1pt padding 圈surface 像素帧必须与 raw/容器完全一致);④ 像素除法只留作「全是多 pane window」时的兜底。验证法`tput cols` 宽的字符标尺应恰好一行不换行(模拟器连本机 sshd 即可复现/回归)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。 - **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除ghostty 释放 grid内容全丢② matchedGeometry最小化缩回首页卡要求两端同层级独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。 - **导航不得销毁会话**:终端**不能**走 `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 两个面板实例",三态切换视图身份互换 → 动画混乱硬切。 - **侧边栏两态(收起⇄固定展开,悬浮态已被用户裁决砍掉)必须是同一个视图身份**`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)` - **`clipped()` 只裁绘制不裁触摸**:收起态下侧边栏被裁掉的明细区(整行按钮)会伸进终端里抢点击 → 整行按钮用 `LeadingHitShape``contentShape` 限制命中区到当前可见宽度Animatable纯明细控件再加 `allowsHitTesting(expanded)`
- **状态变更必须包在 `TX.Motion` 的动画事务里**`AppRouter` 的 route/modal/sidebar 全走 `animated{}`。裸改 `@Published` → 视图上写好的 `.transition` 拿不到事务,**静默退化成硬切**(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走 `View.txShadow(TX.Elevation.*)`,别手写 `.shadow`(深色底上大半径高透明度会糊成脏斑)。 - **状态变更必须包在 `TX.Motion` 的动画事务里**`AppRouter` 的 route/modal/sidebar 全走 `animated{}`。裸改 `@Published` → 视图上写好的 `.transition` 拿不到事务,**静默退化成硬切**(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走 `View.txShadow(TX.Elevation.*)`,别手写 `.shadow`(深色底上大半径高透明度会糊成脏斑)。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角描边(描边用负 padding `top:-1/trailing:-1` 推出屏外)。Stage Manager 小窗同理。 - **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角描边**四边完整**(右缘落在物理圆角的平直段上无切角问题)——它现在是焦点高亮环,缺一边读作"没画完";旧的「负 padding 推出屏外」只适用当年的灰色装饰边。Stage Manager 小窗同理。
- **软键盘让位必须显式且只让一次**2026-07-26 真机:软键盘一弹终端只剩 6 行):系统隐式 keyboard avoidance 压缩画布一次、`footBand(safeBottom)` 又把键盘高度算进底栏再压一次 = 双重让位。修法:`SessionCanvas .ignoresSafeArea(.keyboard)` 关掉隐式让位,`TerminalScreen``TXKeyboardObserver.height` 对舞台 `.padding(.bottom)` **一次**(键盘可见时 footBand 的 safeBottom 归 0悬浮小键盘报 0 不让位)。
- **终端键盘焦点**`TerminalSession.focusTick` 脉冲route 切到本会话时 +1→ raw / tmux 活动 pane 的 `@FocusState` **先翻 false 再置 true**(下一拍)→ becomeFirstResponder**选中会话即可直接输入**。坑:`terminalFocusOnAppear` 挂载时就把 FocusState 挂在 true 上(当时视图未进 window、聚焦失败直接 `focused = true` 是 true→true **不触发 updateUIView**wrapper 的 `synchronizeFocus` 永远不重试 —— 必须强制翻转surface 就绪(`markSurfaceReady`/`onReady`)后按 `focusEligible`route 正指向本会话)再补一次,覆盖「进终端早于 pane 挂载」的扑空。**妙控触摸板点击是 indirectPointer touch**vendor 的 `UITerminalView.handleIndirectPointerTouches` 原来只在 Catalyst 下 becomeFirstResponderiPadOS 漏了 → 触摸板点终端永远拿不到焦点(已补,改 vendor/libghostty-spm 源)。
- **终端焦点高亮画在 pane 容器边框上**(在线转 accent单 pane 曾自画 accent 描边,但与容器灰边**同位互盖**(左/底被吞、顶边因内容内衬悬空出 10pt 间隔)。单 pane 不再自画描边/圆角,容器是唯一边框;多 pane 保留 pane 间描边。
- **沉浸页的安全区由底栏自己让位**:整屏 `ignoresSafeArea()`,底部区高 = `TX.Layout.footBand(safeBottom)` = 34 + 安全区,内容**居中**(贴住物理底边又让开 home indicator。**坑**:只放开顶边时,颜色型 `.background` 会溢进底部安全区、内容却停在上沿 → 屏幕最下面一条 24pt 空带 + 轨道底色铺满,一眼没对齐;遮罩态/Pin 态面板脚还会差 24pt一钉住就跳。安全区读数用 `TXScreenMetrics``GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0 - **沉浸页的安全区由底栏自己让位**:整屏 `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()` - **`.frame(height:)` 压不住内容驱动的最小高度**:它只是给了个框,不声明撑满的子视图按自身理想高度**居中**放进去 → 一排卡片高矮不齐。要么子视图自己 `.frame(maxHeight: .infinity)`,要么(内容天生比框高时,如 `TXTerminalMirror` 的行堆叠)把内容放进 `Color.clear``overlay` —— overlay 不参与父视图定尺,尺寸完全由外部决定,溢出交给 `clipped()`
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。 - **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。
- **嵌套 ObservableObject 不冒泡**`SessionManager``@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。 - **嵌套 ObservableObject 不冒泡**`SessionManager``@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。 - 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> </content>

View File

@@ -12,6 +12,11 @@
<string>JetBrainsMono-SemiBold.ttf</string> <string>JetBrainsMono-SemiBold.ttf</string>
<string>JetBrainsMono-Bold.ttf</string> <string>JetBrainsMono-Bold.ttf</string>
</array> </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> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>

View File

@@ -32,13 +32,11 @@ enum SidebarState: Equatable {
} }
} }
/// router `@State`W/D URL /// router `@State`W URL
/// ///
enum AppModal: Equatable { enum AppModal: Equatable {
/// 3c /// 3c
case closeConfirm case closeConfirm
/// 3b`vertical` = D/ D
case splitMenu(vertical: Bool)
} }
/// ///
@@ -46,13 +44,13 @@ enum AppModal: Equatable {
final class AppRouter: ObservableObject { final class AppRouter: ObservableObject {
@Published var route: AppRoute = .home @Published var route: AppRoute = .home
@Published var sidebar: SidebarState = .collapsed @Published var sidebar: SidebarState = .collapsed
/// / ///
@Published var modal: AppModal? @Published var modal: AppModal?
/// `TX.Motion.standard`稿 200260ms easeOut /// `TX.Motion.standard`稿 200260ms easeOut
/// ///
/// ** / / 线** `transition` /// ** / / 线** `transition`
/// 退 "" /// 退 ""
/// router URL /// router URL
private func animated(_ change: () -> Void) { private func animated(_ change: () -> Void) {
withAnimation(TX.Motion.standard, change) withAnimation(TX.Motion.standard, change)
@@ -76,7 +74,7 @@ final class AppRouter: ObservableObject {
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed } animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
} }
/// /WDD URL /// /W URL
func present(_ modal: AppModal) { animated { self.modal = modal } } func present(_ modal: AppModal) { animated { self.modal = modal } }
func dismissModal() { animated { modal = nil } } func dismissModal() { animated { modal = nil } }
} }

View File

@@ -103,22 +103,21 @@ struct RootView: View {
.statusBarHidden(router.route != .home) .statusBarHidden(router.route != .home)
// tmux **** // tmux ****
// RootView // RootView
.sheet(isPresented: Binding( // **** sheet+""
get: { manager.sessionAwaitingChoice != nil }, // = sheet =
set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } } .overlay {
)) {
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice { if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
SessionPickerSheet( SessionPickerDialog(
host: s.displayName, host: s.displayName,
choice: choice, choice: choice,
onAttach: { s.attachTmuxSession($0) }, onAttach: { s.attachTmuxSession($0) },
onCreate: { s.createTmuxSession($0) }, onCreate: { s.createTmuxSession($0) },
onNative: { s.useNativeTerminal() }) onNative: { s.useNativeTerminal() },
.environmentObject(theme) onCancel: { manager.close(s.id) })
.presentationDetents([.medium, .large]) .transition(TX.Motion.pop)
.presentationDragIndicator(.hidden)
} }
} }
.animation(TX.Motion.standard, value: manager.sessionAwaitingChoice?.id)
// / tmux / // / tmux /
.onChange(of: manager.enterRequest) { _, id in .onChange(of: manager.enterRequest) { _, id in
guard let id else { return } guard let id else { return }
@@ -160,6 +159,9 @@ struct SessionCanvas: View {
.allowsHitTesting(visible) .allowsHitTesting(visible)
} }
} }
// TerminalScreen TXKeyboardObserver
// **** + footBand 6 TXParts
.ignoresSafeArea(.keyboard)
} }
} }
@@ -179,8 +181,8 @@ struct TerminalScreen: View {
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize @Environment(\.horizontalSizeClass) private var hSize
@State private var sidebarSearch = "" @State private var sidebarSearch = ""
/// tmux /// TXKeyboardObserver
@State private var showTmuxInstall = false @StateObject private var keyboard = TXKeyboardObserver()
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
var body: some View { var body: some View {
@@ -189,6 +191,11 @@ struct TerminalScreen: View {
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom) screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea() .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 { private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
@@ -196,15 +203,15 @@ struct TerminalScreen: View {
// **** TerminalSidebar // **** TerminalSidebar
// 56 288 // 56 288
// resize // resize
TerminalSidebar(manager: manager, router: router, session: session, TerminalSidebar(manager: manager, router: router,
tailscaleStore: tailscaleStore, tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom, railWidth: railWidth, safeBottom: safeBottom,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost, onSettings: goHomeToSettings,
search: $sidebarSearch) search: $sidebarSearch)
stage(safeTop: safeTop, safeBottom: safeBottom) stage(safeTop: safeTop, safeBottom: safeBottom)
} }
.background(p.bgDeep) .background(p.bgDeep)
// 3c3b alert/sheet // 3c alert/sheet
.overlay { .overlay {
switch router.modal { switch router.modal {
case .closeConfirm: case .closeConfirm:
@@ -213,24 +220,9 @@ struct TerminalScreen: View {
onDetach: { router.dismissModal(); closeSession(kill: false) }, onDetach: { router.dismissModal(); closeSession(kill: false) },
onKill: { router.dismissModal(); closeSession(kill: true) }, onKill: { router.dismissModal(); closeSession(kill: true) },
onCancel: { router.dismissModal() }) 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: case nil:
EmptyView() 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 使 // host key 使
.alert("⚠️ Host Key 已变化", isPresented: Binding( .alert("⚠️ Host Key 已变化", isPresented: Binding(
@@ -249,56 +241,60 @@ struct TerminalScreen: View {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
} }
/// + / 齿 /// 齿
/// UI-4 / private func goHomeToSettings() {
private func goHomeToAddHost() {
router.goHome() router.goHome()
} }
// MARK: - // MARK: -
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View { private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
ZStack(alignment: .top) { VStack(spacing: 0) {
VStack(spacing: 0) { // 稿/ + + tmux window tab
// tmux 2c pane // **** chrome pane
if session.tmuxUnavailable { TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } }, .padding(.horizontal, TX.Space.m)
onDismiss: { // iPad 14/
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() } .padding(.top, TX.Layout.capsuleTop(safeTop))
}) .padding(.bottom, TX.Space.s)
.padding(.top, TX.Layout.capsuleInset) // tmux tmux
.transition(.opacity) // " tmux · "
} paneArea
// pane **** + // pane
// surface 86pt .padding(.top, TX.Space.s)
paneArea .frame(maxWidth: .infinity, maxHeight: .infinity)
// 2c 122pt .background(p.terminalBackground)
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset) .clipShape(paneShape)
.frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(
.background(p.terminalBackground) // ****线 accent pane
.clipShape(paneShape) // accent /
.overlay( // 10pt 2026-07-26
paneShape.strokeBorder(p.border, lineWidth: 1) //
// / //
// 线 // ""
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1)) paneShape.strokeBorder(session.isLive ? TXAccent.border : p.border,
) lineWidth: 1)
// **** 2pt / .animation(TX.Motion.quick, value: session.isLive)
// pane 线 )
.padding(.leading, TX.Layout.paneGap) // **** 2pt
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom) // pane 线
} .padding(.leading, TX.Layout.paneGap)
capsules(safeTop: safeTop) // home indicator
TerminalActionBar(session: session,
safeBottom: keyboard.height > 0 ? 0 : safeBottom)
} }
// ****/
.padding(.bottom, keyboard.height)
.background(p.bgDeep) .background(p.bgDeep)
.animation(TX.Motion.standard, value: keyboard.height)
} }
/// pane ** app chrome ** /// pane ** app chrome **
/// ///
/// iPad/iPhone 6pt /// iPad/iPhone 6pt
/// "" /// ""
/// /**** /// ****
/// pane chrome Stage Manager /// pane chrome
private var paneShape: UnevenRoundedRectangle { private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle( UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane, topLeadingRadius: TX.Radius.pane,
@@ -310,9 +306,11 @@ struct TerminalScreen: View {
@ViewBuilder private var paneArea: some View { @ViewBuilder private var paneArea: some View {
ZStack { ZStack {
if let tmux = session.tmuxController { if let tmux = session.tmuxController {
TmuxStage(controller: tmux) TmuxStage(controller: tmux, focusTick: session.focusTick,
focusEligible: router.route.sessionID == session.id)
} else { } else {
RawTerminalView(model: session) RawTerminalView(model: session,
focusEligible: router.route.sessionID == session.id)
} }
// / / // / /
TerminalStateOverlay(session: session, 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:)` /// **** `closeSession(kill:)`
private func requestClose() { private func requestClose() {
router.present(.closeConfirm) router.present(.closeConfirm)
@@ -365,69 +352,37 @@ struct TerminalScreen: View {
router.goHome() 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 /// tmux
struct RawTerminalView: View { struct RawTerminalView: View {
@ObservedObject var model: TerminalSession @ObservedObject var model: TerminalSession
///
let focusEligible: Bool
@FocusState private var focused: Bool @FocusState private var focused: Bool
var body: some View { var body: some View {
TerminalSurfaceView(context: model.state) TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused) .terminalFocusOnAppear($focused)
// / ** false true**
// onAppear FocusState true window
// truetrue updateUIViewbecomeFirstResponder
.onChange(of: model.focusTick) { _, _ in
focused = false
Task { @MainActor in focused = true }
}
.task { .task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil { for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000) try? await Task.sleep(nanoseconds: 30_000_000)
} }
model.markSurfaceReady() 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 /// window / window 稿 tab
struct TmuxStage: View { struct TmuxStage: View {
@ObservedObject var controller: TmuxController @ObservedObject var controller: TmuxController
/// TerminalSession.focusTick pane
let focusTick: Int
///
let focusEligible: Bool
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
@Environment(\.displayScale) private var displayScale @Environment(\.displayScale) private var displayScale
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
@@ -456,7 +415,8 @@ struct TmuxStage: View {
// %output // %output
ZStack { ZStack {
ForEach(controller.windows) { win in 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) .opacity(win.id == controller.activeWindowID ? 1 : 0)
.allowsHitTesting(win.id == controller.activeWindowID) .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 { struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow @ObservedObject var window: TmuxWindow
let controller: TmuxController let controller: TmuxController
let focusTick: Int
let focusEligible: Bool
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
@@ -489,12 +453,18 @@ struct TmuxWindowView: View {
let root = layout.root.rect let root = layout.root.rect
let cw = geo.size.width / CGFloat(max(1, root.width)) let cw = geo.size.width / CGFloat(max(1, root.width))
let ch = geo.size.height / CGFloat(max(1, root.height)) let ch = geo.size.height / CGFloat(max(1, root.height))
let items = leaves(layout.root)
ZStack(alignment: .topLeading) { 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] { if let surface = window.panes[item.pane] {
TmuxPaneView( TmuxPaneView(
surface: surface, surface: surface,
isActive: window.activePane == item.pane, isActive: window.activePane == item.pane,
// panesurface
// paneViewportChanged
solo: items.count == 1,
focusTick: focusTick,
focusEligible: focusEligible,
onTap: { controller.selectPane(item.pane, in: window.id) }, onTap: { controller.selectPane(item.pane, in: window.id) },
onReady: { controller.markPaneReady(item.pane) } onReady: { controller.markPaneReady(item.pane) }
) )
@@ -504,10 +474,6 @@ struct TmuxWindowView: View {
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch) 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 { } else {
Color.clear 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 /// pane + + pane 2pt
struct TmuxPaneView: View { struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface @ObservedObject var surface: TmuxPaneSurface
let isActive: Bool let isActive: Bool
/// window pane pane surface ==
let solo: Bool
/// pane becomeFirstResponder
let focusTick: Int
///
let focusEligible: Bool
let onTap: () -> Void let onTap: () -> Void
let onReady: () -> Void let onReady: () -> Void
@FocusState private var focused: Bool @FocusState private var focused: Bool
@@ -660,25 +512,43 @@ struct TmuxPaneView: View {
var body: some View { var body: some View {
TerminalSurfaceView(context: surface.state) TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused) .terminalFocusOnAppear($focused)
.clipShape(RoundedRectangle(cornerRadius: 6)) // pane paneShape/
.overlay( // TerminalScreen.paneArea pane pane
RoundedRectangle(cornerRadius: 6) .clipShape(RoundedRectangle(cornerRadius: solo ? 0 : 6))
.strokeBorder(isActive ? TXAccent.border : p.divider, .overlay {
lineWidth: isActive ? 1 : 0.5) if !solo {
) RoundedRectangle(cornerRadius: 6)
.strokeBorder(isActive ? TXAccent.border : p.divider,
lineWidth: isActive ? 1 : 0.5)
}
}
// pane tap-catcherTerminalSurfaceView pane // pane tap-catcherTerminalSurfaceView pane
.overlay { .overlay {
if !isActive { if !isActive {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() } 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 } .onChange(of: isActive) { _, active in focused = active }
// / pane
// ** false true**FocusState true
// truetrue updateUIView RawTerminalView
.onChange(of: focusTick) { _, _ in
guard isActive else { return }
focused = false
Task { @MainActor in focused = true }
}
.task { .task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil { for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000) try? await Task.sleep(nanoseconds: 30_000_000)
} }
onReady() onReady()
// surface pane focusTick
if focusEligible, isActive {
focused = false
try? await Task.sleep(nanoseconds: 50_000_000)
focused = true
}
} }
} }
} }

View File

@@ -18,6 +18,8 @@ struct HomeView: View {
@State private var showEditor = false @State private var showEditor = false
@State private var editing: SavedHost? @State private var editing: SavedHost?
@State private var showSettings = false @State private var showSettings = false
/// `CloseConfirmDialog`
@State private var closing: TerminalSession?
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
@@ -64,6 +66,30 @@ struct HomeView: View {
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore) 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 { private func content(safeBottom: CGFloat) -> some View {
@@ -85,10 +111,13 @@ struct HomeView: View {
.padding(.top, TX.Space.xs) .padding(.top, TX.Space.xs)
sessionCards sessionCards
} }
HStack(alignment: .center) { HStack(alignment: .center, spacing: TX.Space.s) {
TXSectionLabel(text: "全部主机") TXSectionLabel(text: "全部主机")
Spacer() Spacer()
if !groups.isEmpty { chips } if !groups.isEmpty { chips }
//
TXSearchField(text: $search, placeholder: "搜索主机")
.frame(minWidth: 120, maxWidth: 220)
} }
.padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s) .padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s)
if filteredHosts.isEmpty { if filteredHosts.isEmpty {
@@ -192,18 +221,9 @@ struct HomeView: View {
.foregroundStyle(p.textWeak) .foregroundStyle(p.textWeak)
} }
Spacer(minLength: TX.Space.s) Spacer(minLength: TX.Space.s)
HStack(spacing: TX.Space.s) { // 线
TXSearchField(text: $search, placeholder: "搜索主机") //
// 220 / iPhone TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
//
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
} }
.padding(.horizontal, gutter) .padding(.horizontal, gutter)
.padding(.top, TX.Space.l) .padding(.top, TX.Space.l)
@@ -258,11 +278,14 @@ struct HomeView: View {
SessionCard( SessionCard(
session: s, session: s,
shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1, shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1,
isFocused: manager.focusedID == s.id isFocused: manager.focusedID == s.id,
) { onOpen: {
manager.focus(s) // activate +
router.enter(s.id) manager.activate(s)
} router.enter(s.id)
},
onClose: { withAnimation(TX.Motion.standard) { closing = s } }
)
.aspectRatio(4.0 / 3.0, contentMode: .fit) .aspectRatio(4.0 / 3.0, contentMode: .fit)
} }
} }
@@ -301,12 +324,23 @@ struct HomeView: View {
editing = nil editing = nil
showEditor = true showEditor = true
} label: { } label: {
VStack { // = HostTile 30pt +
Text("添加主机") // **** minHeight 96
.font(TX.Font.mono(12, .medium)) VStack(alignment: .leading, spacing: TX.Space.s) {
.foregroundStyle(p.textWeak) 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( .background(
RoundedRectangle(cornerRadius: TX.Radius.card) RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
@@ -386,6 +420,8 @@ struct SessionCard: View {
let shortcutIndex: Int let shortcutIndex: Int
let isFocused: Bool let isFocused: Bool
let onOpen: () -> Void let onOpen: () -> Void
///
let onClose: () -> Void
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
@@ -435,6 +471,19 @@ struct SessionCard: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.txPress) .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 { private var cardHead: some View {
@@ -452,6 +501,8 @@ struct SessionCard: View {
} }
Spacer(minLength: TX.Space.xs) Spacer(minLength: TX.Space.xs)
linkTag linkTag
// overlay
Color.clear.frame(width: 26, height: 1)
} }
.padding(.horizontal, TX.Space.s) .padding(.horizontal, TX.Space.s)
.frame(height: 36) .frame(height: 36)
@@ -461,7 +512,7 @@ struct SessionCard: View {
@ViewBuilder private var linkTag: some View { @ViewBuilder private var linkTag: some View {
switch session.link { switch session.link {
case .direct: case .direct:
tag("direct", p.online) tag("直连", p.online)
case .relay: case .relay:
tag("tsnet", p.online) tag("tsnet", p.online)
case .connecting: case .connecting:
@@ -555,7 +606,8 @@ struct HostTile: View {
let host: SavedHost let host: SavedHost
let credName: String? let credName: String?
let hasTsnet: Bool let hasTsnet: Bool
/// /// ****
/// /
let liveSession: TerminalSession? let liveSession: TerminalSession?
let onTap: () -> Void let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager

View File

@@ -1,166 +1,10 @@
import SwiftUI import SwiftUI
/// split + / // HostEditorView Termius `HostsListView`/`HostCard`/`SessionStatusRow`
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 }
var body: some View { /// + = Keychain / + / Tailscale+
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+
struct HostEditorView: View { struct HostEditorView: View {
let existing: SavedHost? let existing: SavedHost?
@ObservedObject var credStore: FileCredentialStore @ObservedObject var credStore: FileCredentialStore
@@ -184,6 +28,7 @@ struct HostEditorView: View {
@State private var tailscaleID: UUID? @State private var tailscaleID: UUID?
@State private var useMosh = false @State private var useMosh = false
@State private var showNewCredential = false @State private var showNewCredential = false
@State private var showNewTailscale = false
/// chips /// chips
@State private var group = "" @State private var group = ""
@State private var os = "" @State private var os = ""
@@ -264,13 +109,19 @@ struct HostEditorView: View {
authMode = .credential authMode = .credential
} }
} }
.sheet(isPresented: $showNewTailscale) {
// Tailscale
TailscaleEditorView(existing: nil, store: tailscaleStore) { conn in
tailscaleID = conn.id
}
}
.onAppear(perform: populate) .onAppear(perform: populate)
} }
.tint(p.accent) .tint(p.accent)
} }
@ViewBuilder private var authSection: some View { @ViewBuilder private var authSection: some View {
Section("认证") { Section {
Picker("方式", selection: $authMode) { Picker("方式", selection: $authMode) {
Text("直接输入").tag(AuthMode.inline) Text("直接输入").tag(AuthMode.inline)
Text("从 Keychain 选择").tag(AuthMode.credential) Text("从 Keychain 选择").tag(AuthMode.credential)
@@ -294,18 +145,28 @@ struct HostEditorView: View {
} }
.txRow(p) .txRow(p)
} }
} header: {
Text("认证")
} footer: {
if authMode == .inline {
Text("保存时自动存为 Keychain 凭据(可在 设置 → Keychain 里管理),主机只保留引用。")
}
} }
} }
@ViewBuilder private var networkSection: some View { @ViewBuilder private var networkSection: some View {
Section("网络(出口)") { Section("网络(出口)") {
Picker("经由", selection: $tailscaleID) { Picker("经由", selection: $tailscaleID) {
Text("Direct直连").tag(UUID?.none) Text("直连").tag(UUID?.none)
ForEach(tailscaleStore.connections) { conn in ForEach(tailscaleStore.connections) { conn in
Text(conn.name).tag(UUID?.some(conn.id)) Text(conn.name).tag(UUID?.some(conn.id))
} }
} }
.txRow(p) .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 { if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey {
Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。") Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。")
.font(.system(size: 12)).foregroundStyle(p.warning).txRow(p) .font(.system(size: 12)).foregroundStyle(p.warning).txRow(p)
@@ -330,7 +191,22 @@ struct HostEditorView: View {
private func save() { private func save() {
let auth: SavedHost.AuthRef let auth: SavedHost.AuthRef
switch authMode { 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: case .credential:
guard let id = selectedCredentialID else { return } guard let id = selectedCredentialID else { return }
auth = .credential(id: id) auth = .credential(id: id)

View File

@@ -5,13 +5,13 @@ import SwiftUI
// " + " // " + "
// 288pt railWidth `clipped()` // 288pt railWidth `clipped()`
// = // =
// railWidth ****logo() / / / / window / // railWidth ****logo() / / / window /
// / 齿**** // / 齿****
// //
// / // /
// 稿Logo =»« // 稿Logo =»«
// //
// ****`clipped()` //window // ****`clipped()` /window
// `LeadingHitShape` // `LeadingHitShape`
// Pin window // Pin window
// `allowsHitTesting(expanded)` // `allowsHitTesting(expanded)`
@@ -32,14 +32,11 @@ private struct LeadingHitShape: Shape {
struct TerminalSidebar: View { struct TerminalSidebar: View {
@ObservedObject var manager: SessionManager @ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter @ObservedObject var router: AppRouter
/// tmux window
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore @ObservedObject var tailscaleStore: TailscaleStore
/// 48 = 线 /// 48 = 线
let railWidth: CGFloat let railWidth: CGFloat
/// home indicator /// home indicator
let safeBottom: CGFloat let safeBottom: CGFloat
let onNewHost: () -> Void
let onSettings: () -> Void let onSettings: () -> Void
@Binding var search: String @Binding var search: String
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
@@ -56,6 +53,8 @@ struct TerminalSidebar: View {
.clipped() .clipped()
} }
// tmux window Tab
// TmuxWindowTabGroup稿 window
private var content: some View { private var content: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header header
@@ -64,10 +63,7 @@ struct TerminalSidebar: View {
homeRow homeRow
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)") sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
sessionRows sessionRows
newHostRow //
tmuxDivider
tmuxLabelRow
tmuxRows
Spacer(minLength: TX.Space.s) Spacer(minLength: TX.Space.s)
tailscaleRow tailscaleRow
footRow footRow
@@ -185,7 +181,7 @@ struct TerminalSidebar: View {
// MARK: - // MARK: -
@ViewBuilder private var sessionRows: some View { @ViewBuilder private var sessionRows: some View {
if manager.activeSessions.isEmpty { if manager.sessions.isEmpty {
Text("没有在跑的会话") Text("没有在跑的会话")
.font(TX.Font.mono(11.5)) .font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak) .foregroundStyle(p.textWeak)
@@ -193,139 +189,20 @@ struct TerminalSidebar: View {
.frame(height: 32, alignment: .leading) .frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0) .opacity(expanded ? 1 : 0)
} }
ForEach(manager.activeSessions) { s in // **** lastActiveAt
//
ForEach(manager.sessions) { s in
SidebarSessionRow(session: s, SidebarSessionRow(session: s,
isSelected: router.route.sessionID == s.id, isSelected: router.route.sessionID == s.id,
expanded: expanded, railWidth: railWidth, expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) { hitWidth: currentWidth) {
manager.focus(s) // activate + =
manager.activate(s)
router.enter(s.id) 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 · 线 + 齿 // MARK: - tailnet · 线 + 齿
/// tailnet /// tailnet
@@ -391,6 +268,7 @@ enum RailSidebarStyle {
} }
// MARK: - // MARK: -
// window window Tab TerminalChrome.TmuxWindowTabGroup
/// 30pt + ·window title + /// 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)
}
}

View File

@@ -70,16 +70,11 @@ final class SessionManager: ObservableObject {
// MARK: - / // MARK: - /
/// **** /// ****稿 =
/// **** /// / `activate`
/// **** tmux / `enterRequest` /// **** tmux / `enterRequest`
@discardableResult @discardableResult
func open(host: SavedHost) -> TerminalSession { 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() let s = makeSession()
sessions.append(s) sessions.append(s)
focus(s) focus(s)
@@ -88,6 +83,15 @@ final class SessionManager: ObservableObject {
return s 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 / / /// = / tmux / /
@@ -166,8 +170,7 @@ final class SessionManager: ObservableObject {
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue } guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
let s = makeSession() let s = makeSession()
s.injectFixture(host: host, lines: spec.lines, link: spec.link, s.injectFixture(host: host, lines: spec.lines, link: spec.link,
mosh: host.useMosh, tmux: spec.tmux, mosh: host.useMosh, tmux: spec.tmux)
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
// //
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60) s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
sessions.append(s) sessions.append(s)

View File

@@ -3,18 +3,22 @@ import TXCore
/// ///
/// ///
/// ** tmux ** · ** tmux ** · **** tmux /// **** sheet + ""
/// ** tmux sheet** /// Herdr/ Header `CloseConfirmDialog`
/// TXModalScrim + surfaceDark + borderStrong + dialog
/// ///
/// ""**** /// ** tmux **· ** tmux ** · ****
/// `Herdr` / `` /// ** tmux **
struct SessionPickerSheet: View { /// =
struct SessionPickerDialog: View {
/// "" /// ""
let host: String let host: String
let choice: TmuxSessionChoice let choice: TmuxSessionChoice
let onAttach: (String) -> Void let onAttach: (String) -> Void
let onCreate: (String) -> Void let onCreate: (String) -> Void
let onNative: () -> Void let onNative: () -> Void
///
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
@State private var newName = "" @State private var newName = ""
@State private var creating = false @State private var creating = false
@@ -22,28 +26,32 @@ struct SessionPickerSheet: View {
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: TX.Space.m) { TXModalScrim(onDismiss: onCancel) {
grabber VStack(alignment: .leading, spacing: TX.Space.m) {
title title
header if choice.sessions.isEmpty {
if choice.sessions.isEmpty { emptyState
emptyState } else {
} else { sessionList
sessionList }
newSessionRow
nativeRow
} }
newSessionRow .padding(TX.Space.l)
Spacer(minLength: 0) .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 { private var title: some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)") Text("连接到 \(host)")
.font(TX.Font.mono(17, .medium)) .font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text) .foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端") Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12)) .font(TX.Font.mono(12))
@@ -51,65 +59,22 @@ struct SessionPickerSheet: View {
} }
} }
private var grabber: some View { // MARK: -
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: -
private var sessionList: some View { 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) { VStack(spacing: 0) {
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
Button { onAttach(s.name) } label: { 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 { private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: TX.Space.s) { HStack(spacing: TX.Space.s) {
Text(s.name) Text(s.name)
.font(TX.Font.mono(22, .medium)) .font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text) .foregroundStyle(p.text)
// tmux // tmux
TXBadge(text: "活跃", emphasized: true) TXBadge(text: "活跃", emphasized: true)
@@ -137,13 +101,16 @@ struct SessionPickerSheet: View {
TXBadge(text: "已被接入") TXBadge(text: "已被接入")
} }
Spacer(minLength: 0) Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textFaint)
} }
Text(subtitle(s)) Text(subtitle(s))
.font(TX.Font.mono(13)) .font(TX.Font.mono(11))
.foregroundStyle(p.textWeak) .foregroundStyle(p.textWeak)
} }
.padding(.horizontal, TX.Space.m) .padding(.horizontal, TX.Space.m)
.padding(.vertical, 18) .padding(.vertical, TX.Space.s + 2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -157,19 +124,19 @@ struct SessionPickerSheet: View {
private var emptyState: some View { private var emptyState: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("这台主机上还没有 tmux 会话") Text("这台主机上还没有 tmux 会话")
.font(TX.Font.mono(15, .medium)) .font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text) .foregroundStyle(p.text)
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。") Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
.font(TX.Font.mono(12)) .font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak) .foregroundStyle(p.textWeak)
.lineSpacing(3) .lineSpacing(3)
} }
.padding(TX.Space.m) .padding(TX.Space.m)
.frame(maxWidth: .infinity, alignment: .leading) .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 { @ViewBuilder private var newSessionRow: some View {
if creating { if creating {
@@ -189,18 +156,46 @@ struct SessionPickerSheet: View {
} label: { } label: {
HStack(spacing: TX.Space.s) { HStack(spacing: TX.Space.s) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 12, weight: .medium))
Text("新建 tmux 会话…") Text("新建 tmux 会话…")
.font(TX.Font.mono(15, .medium)) .font(TX.Font.mono(13, .medium))
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.foregroundStyle(TXAccent.textBright) .foregroundStyle(TXAccent.textBright)
.padding(.horizontal, TX.Space.m) .padding(.horizontal, TX.Space.m)
.frame(height: 56) .frame(height: 44)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.txPress) .buttonStyle(.txPress)
} }
} }
// MARK: - ""
private var nativeRow: some View {
Button(action: onNative) {
HStack(spacing: TX.Space.s) {
Image(systemName: "terminal")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.text3)
Text("原生终端")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text("不经过 tmux")
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UIKit
// 稿 / / docs/design/UI-DESIGN-HANDOFF.md // 稿 / / 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) } 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` /// 1. ****`ignoresSafeArea`
@@ -171,9 +207,10 @@ enum TXTech {
/// mosh / SSH / tsnet / /// mosh / SSH / tsnet /
/// ///
/// `tint` `TXTech``emphasized` app ** tsnet** /// ** + **Shimo
/// /// 线`tint` `TXTech``emphasized` app
/// /// ** tsnet**
///
struct TXBadge: View { struct TXBadge: View {
let text: String let text: String
var tint: Color? var tint: Color?
@@ -182,28 +219,22 @@ struct TXBadge: View {
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) } 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 { var body: some View {
Text(text) Text(text)
.font(TX.Font.mono(11, .medium)) .font(TX.Font.mono(11, .medium))
.foregroundStyle(fg) .foregroundStyle(fg)
.padding(.horizontal, 7) .padding(.horizontal, 8)
.padding(.vertical, 3) .padding(.vertical, 3)
.background { .background {
let shape = RoundedRectangle(cornerRadius: 5)
if emphasized { if emphasized {
shape.fill(TXAccent.selectedBg) Capsule().fill(TXAccent.selectedBg)
} else if let tint { } else if let tint {
shape.fill(tint.opacity(0.12)) Capsule().fill(tint.opacity(0.14))
} else { } else {
shape.fill(p.surface) Capsule().fill(p.surface)
} }
} }
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, lineWidth: 1))
} }
} }

View File

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

View File

@@ -1,39 +1,20 @@
import SwiftUI import SwiftUI
// 稿 `1b -` chrome + + // `1b` ** chrome **tab
// **** bgDeep pane 86pt
// //
// absolute + blur // Shimo ** + **
// 86pt padding clipped //
// [ ] · · · [ tab ] [Mosh/SSH ]
// tab = tmux window window tab
/// blur + + + 11 /// + + tmux window tab +
/// ///
/// ****`TX.Elevation.floating` /// Shimo / ****macOS +
/// `0 10px 30px rgba(0,0,0,.55)` /// ` · cwd · ` `·`
/// "" .92 /// tab = accent Mosh/SSH
private struct FloatingCapsule<Content: View>: View { /// mosh 线
@EnvironmentObject var theme: TXThemeManager struct TerminalTopBar: View {
@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 {
@ObservedObject var session: TerminalSession @ObservedObject var session: TerminalSession
let onClose: () -> Void let onClose: () -> Void
let onMinimize: () -> Void let onMinimize: () -> Void
@@ -41,51 +22,84 @@ struct TerminalTitleCapsule: View {
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
var body: some View { var body: some View {
FloatingCapsule { HStack(spacing: TX.Space.m) {
HStack(spacing: TX.Space.xs) { // 沿 macOS
// 沿 macOS HStack(spacing: TX.Space.s) {
// windowButton
windowButton(icon: "xmark", tint: TXWindowDot.close, windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose) label: "关闭会话", action: onClose)
// chevron 9.5pt // chevron =
//
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize, windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize) 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)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 resize
// GridBox.onGridChange session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
} }
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, .medium))
.foregroundStyle(p.text3)
.lineLimit(1)
if !contextLine.isEmpty {
dotSeparator
Text(contextLine)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
dotSeparator
// resize GridBox.onGridChange
// session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
} }
} }
/// window title · cwd稿 3a tmux 退 private var dotSeparator: some View {
Text("·")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
/// 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 { private var contextLine: String {
guard let t = session.tmuxSummary else { guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText return session.isLive ? "" : session.phaseText
} }
var parts: [String] = [] return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
} }
/// `/Users/yz/src/x` `~/src/x`稿 /// `/Users/yz/src/x` `~/src/x`
static func abbreviate(_ path: String) -> String { static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory() let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) } if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
@@ -99,31 +113,27 @@ struct TerminalTitleCapsule: View {
} }
private var gridLabel: String { 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 let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)" return "\(g.cols)×\(g.rows)"
} }
/// / /// / ****Shimo + hover
/// /// ==
/// macOS 13pt **** ///
/// mac hover hover 13pt
/// "" mac app
///
///
/// 22pt + ****`` / `` =
/// == 14% / 26×34
private func windowButton(icon: String, tint: Color, label: String, private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View { action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 9.5, weight: .bold)) .font(.system(size: 11, weight: .bold))
.foregroundStyle(tint) .foregroundStyle(Color.black.opacity(0.55))
.frame(width: 22, height: 22) .frame(width: 26, height: 26)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7)) .background(tint, in: Circle())
.overlay( .frame(width: 30, height: 40)
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
)
.frame(width: 26, height: 34)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.txPressTight) .buttonStyle(.txPressTight)
@@ -131,84 +141,101 @@ struct TerminalTitleCapsule: View {
} }
} }
/// `tsnet | mosh | tmux` 1×16pt 线 /// tmux window **tab **Shimo +
/// chip
/// ///
/// **** tmux /// **** chip accent chip
/// tmux · RTT HANDOFF §12.4 /// `+` window tab
struct TerminalStatusCapsule: View { /// controller attach tmux / fixture
struct TmuxWindowTabGroup: View {
@ObservedObject var session: TerminalSession @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 chiptitle/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 @EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette } private var p: TXPalette { theme.palette }
var body: some View { 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) { HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) } Text(index)
Text(text) .font(TX.Font.mono(10.5, .semibold))
.font(TX.Font.mono(12, .medium)) .foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
.foregroundStyle(color) Text(title)
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? TXAccent.textBright : p.textWeak)
.lineLimit(1) .lineLimit(1)
} }
} .padding(.horizontal, 12)
.frame(height: 28)
// tsnet / .background(isSelected ? TXAccent.selectedBg : .clear, in: Capsule())
private var egressText: String { .contentShape(Rectangle())
switch session.link { .animation(TX.Motion.quick, value: isSelected)
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…" : ""
} }
} }
@@ -318,153 +345,91 @@ struct TerminalStateOverlay: View {
} }
} }
/// tmux 稿 `2c` App mosh ** App // tmux tmux tmux
/// ** tmux**** //
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View { /// ****window tmux
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)
}
}
}
///
/// ///
/// ****iPad / window / /// **/**`[dot ] | tmux `mosh/SSH
/// `D` /// **** mosh
/// UI-5 线 ///
/// ****/ /tmux
/// tmux ·
/// ///
/// = 34pt + home indicator **** 齿 /// = 34pt + home indicator ****
/// 线 34pt 24pt "" /// 线 34pt 24pt ""
struct TerminalActionBar: View { struct TerminalActionBar: View {
/// / @ObservedObject var session: TerminalSession
let context: String
let actions: [Action]
let safeBottom: CGFloat let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager @EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette } 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 { var body: some View {
HStack(spacing: TX.Space.s) { HStack(spacing: TX.Space.s) {
Text(context) statusLine
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.s) 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) .padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footBand(safeBottom)) .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
} }
} }

View File

@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
// 稿 `3c ` `3b ` // 稿 `3c ``3b `
// //
// //
// - ** / M / = ** // - ** / M / = **
@@ -137,148 +137,3 @@ struct CloseConfirmDialog: View {
.buttonStyle(.txPress) .buttonStyle(.txPress)
} }
} }
// MARK: - 3b
/// **** D / D
/// `split-window` detached `join-pane -s` tmux
///
/// ****
struct SplitMenuView: View {
@ObservedObject var session: TerminalSession
/// true = Dfalse = 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)
}
}

View File

@@ -212,6 +212,9 @@ final class TerminalSession: ObservableObject, Identifiable {
@Published var isMinimized = false @Published var isMinimized = false
/// + /// +
@Published var lastActiveAt = Date() @Published var lastActiveAt = Date()
/// / +1 becomeFirstResponder
/// onAppear
@Published var focusTick = 0
/// / `syncUI()` phase + /// / `syncUI()` phase +
@Published var link: LinkState = .offline @Published var link: LinkState = .offline
@@ -225,14 +228,6 @@ final class TerminalSession: ObservableObject, Identifiable {
/// tmux `tmuxSummary` /// tmux `tmuxSummary`
private var injectedTmuxSummary: 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 /// tmux controller
var tmuxSummary: TmuxSummary? { var tmuxSummary: TmuxSummary? {
@@ -579,10 +574,9 @@ final class TerminalSession: ObservableObject, Identifiable {
/// // UIPreviewFixture /// // UIPreviewFixture
/// `gate` `session.receive` surface attach flush /// `gate` `session.receive` surface attach flush
func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool, func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool,
tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) { tmux: TmuxSummary? = nil) {
isSyntheticFixture = true isSyntheticFixture = true
injectedTmuxSummary = tmux injectedTmuxSummary = tmux
injectedTmuxSessions = tmuxSessions
self.host = host self.host = host
connectionTitle = host.name connectionTitle = host.name
isLive = true isLive = true
@@ -862,19 +856,8 @@ final class TerminalSession: ObservableObject, Identifiable {
scheduleTmuxBootstrap() scheduleTmuxBootstrap()
} }
/// // dismissTmuxBanner / runInTerminal tmux
func dismissTmuxBanner() { tmuxUnavailable = false } // tmuxUnavailable tmux ·
/// 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()
}
}
/// mosh mosh SSH /// mosh mosh SSH
private func sendToTerminal(_ text: String) { private func sendToTerminal(_ text: String) {

View File

@@ -289,12 +289,8 @@ enum TX {
static let sidebarWidth: CGFloat = 288 static let sidebarWidth: CGFloat = 288
/// pane /// pane
static let paneGap: CGFloat = 2 static let paneGap: CGFloat = 2
///
static let paneDragHit: CGFloat = 12
/// ///
static let minTouch: CGFloat = 44 static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// **** / / 齿线 /// **** / / 齿线
/// 线 y home indicator /// 线 y home indicator
/// chrome `TX.Layout.footBand` /// chrome `TX.Layout.footBand`

View File

@@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
init(id: TmuxPaneID, cols: Int, rows: Int, init(id: TmuxPaneID, cols: Int, rows: Int,
onInput: @escaping @Sendable (Data) -> Void, onInput: @escaping @Sendable (Data) -> Void,
onMetrics: @escaping @Sendable (Int, Int) -> Void) { onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
self.id = id self.id = id
self.grid = (cols, rows) self.grid = (cols, rows)
let session = InMemoryTerminalSession( let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // pane send-keys -t %self write: { data in onInput(data) }, // pane send-keys -t %self
// tmux surface resize cell tmux // tmux surface resize ** + cell **cols/rows/cellW/cellH
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) } // 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() let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session)) 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 tabwindow pane /// tmux control-mode `%`- window tabwindow pane
/// "线 b"app `refresh-client -C WxH` tmux pane surface /// "线 b"app `refresh-client -C WxH` tmux pane surface
/// layout rect%output tmux pane / /// layout rect%output tmux pane /
@@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable {
final class TmuxController: ObservableObject { final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = [] @Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID? @Published var activeWindowID: TmuxWindowID?
/// tmux server `listSessions()`
@Published private(set) var sessions: [TmuxSessionInfo] = []
private let parser = TmuxControlParser() private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:] private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
@@ -82,6 +73,8 @@ final class TmuxController: ObservableObject {
var onExit: (() -> Void)? var onExit: (() -> Void)?
private var clientCols = 80, clientRows = 24 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 cellWpx = 0, cellHpx = 0 // cell
private var lastSentCols = 0, lastSentRows = 0 private var lastSentCols = 0, lastSentRows = 0
private var resizeDebounce: Task<Void, Never>? private var resizeDebounce: Task<Void, Never>?
@@ -89,12 +82,11 @@ final class TmuxController: ObservableObject {
// control-mode FIFO // control-mode FIFO
private var attachAcked = false private var attachAcked = false
private enum PendingKind: CustomStringConvertible { private enum PendingKind: CustomStringConvertible {
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID) case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
var description: String { var description: String {
switch self { switch self {
case .ignore: "ignore" case .ignore: "ignore"
case .listWindows: "listWindows" case .listWindows: "listWindows"
case .listSessions: "listSessions"
case .currentSession: "currentSession" case .currentSession: "currentSession"
case .capturePane(let p): "capturePane(%\(p.raw))" case .capturePane(let p): "capturePane(%\(p.raw))"
} }
@@ -122,8 +114,9 @@ final class TmuxController: ObservableObject {
private func kickoffIfReady() { private func kickoffIfReady() {
guard attachAcked, containerKnown, !kickoffDone else { return } guard attachAcked, containerKnown, !kickoffDone else { return }
kickoffDone = true kickoffDone = true
lastSentCols = clientCols; lastSentRows = clientRows //
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(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) pending.append(.ignore)
let sep = Self.fieldSep 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}\"") 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` // attach `demo`
sendCommand("display-message -p \"#{session_name}\"") sendCommand("display-message -p \"#{session_name}\"")
pending.append(.currentSession) pending.append(.currentSession)
listSessions() //
} }
/// model attach tmux refresh-client -C /// 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 /// cell raw pane surface metrics
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } } 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
/// TUIClaude 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 /// iPadOS // + debounce refresh-client -C
/// " × displayScale ÷ cell " surface resize /// " × displayScale ÷ cell "**** pane window
/// paneViewportChanged 1 /
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) { func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return } 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 cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx))) let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
guard cols != lastSentCols || rows != lastSentRows else { return } guard cols != lastSentCols || rows != lastSentRows else { return }
lastSentCols = cols; lastSentRows = rows lastSentCols = cols; lastSentRows = rows
clientCols = cols; clientRows = 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)") 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?.cancel()
resizeDebounce = Task { [weak self] in resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 200_000_000) // try? await Task.sleep(nanoseconds: 200_000_000) //
@@ -256,7 +283,9 @@ final class TmuxController: ObservableObject {
let surface = TmuxPaneSurface( let surface = TmuxPaneSurface(
id: p, cols: cols, rows: rows, id: p, cols: cols, rows: rows,
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) }, 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 surfaceByPane[p] = surface
win.panes[p] = surface win.panes[p] = surface
@@ -293,22 +322,6 @@ final class TmuxController: ObservableObject {
if parts[2] == "1" { activeWindowID = w } if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4])) 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: case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name currentSessionName = name
@@ -376,10 +389,12 @@ final class TmuxController: ObservableObject {
sendCommand("select-window -t @\(w.raw)") sendCommand("select-window -t @\(w.raw)")
} }
func newWindow() { sendCommand("new-window") } // MARK: - UI-4
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
// MARK: - / / resizeUI-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 ****
/// tmux `%exit` `onExit` /// tmux `%exit` `onExit`
@@ -388,38 +403,6 @@ final class TmuxController: ObservableObject {
/// tmux /// tmux
func killSession() { sendCommand("kill-session") } 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 panetmux 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 /// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() } func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }

View File

@@ -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` /// `-txOpenAsHost 1`
static func hostFromLaunchArgs() -> SavedHost? { static func hostFromLaunchArgs() -> SavedHost? {
let d = UserDefaults.standard let d = UserDefaults.standard
@@ -186,10 +174,10 @@ enum UIPreviewFixture {
/// GUIidb companion /// GUIidb companion
/// URL `AppRouter`/`SessionManager` /// URL `AppRouter`/`SessionManager`
/// ///
/// `home` · `minimize` · `close`() · `close-now`() · `split/v|h` /// `home` · `minimize` · `close`() · `close-now`()
/// · `split-now/v|h`() · `resize/<cols>`(resize-pane)
/// · `pick/<>` · `pick-new/<>` · `pick-native` /// · `pick/<>` · `pick-new/<>` · `pick-native`
/// · `dismiss` · `session/<>`1 · `sidebar/toggle|pin` /// · `dismiss` · `session/<>`1 · `sidebar/toggle|pin`
/// `resize/<cols>` pane pane tmux
@MainActor @MainActor
static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool { static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool {
switch path.first { switch path.first {
@@ -204,16 +192,8 @@ enum UIPreviewFixture {
case "close-now": case "close-now":
// //
if let id = router.route.sessionID { router.goHome(); manager.close(id) } 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": case "dismiss":
router.dismissModal() 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": case "pick":
// attach `ui/pick/<name>` // attach `ui/pick/<name>`
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session), 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) guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
else { return false } else { return false }
s.useNativeTerminal() 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": case "session":
guard let n = path.dropFirst().first.flatMap(Int.init), guard let n = path.dropFirst().first.flatMap(Int.init),
let s = manager.session(atShortcutIndex: n - 1) else { return false } let s = manager.session(atShortcutIndex: n - 1) else { return false }
manager.focus(s) manager.activate(s)
router.enter(s.id) router.enter(s.id)
case "sidebar": case "sidebar":
// pin toggle // pin toggle

View File

@@ -142,7 +142,8 @@
### 11.5 UI`ContentView` + 4 个 View 文件 + `UIComponents.swift` ### 11.5 UI`ContentView` + 4 个 View 文件 + `UIComponents.swift`
- `RootSplitView`=`NavigationSplitView` 侧栏连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情iPhone 紧凑自动坍缩终端改 `.fullScreenCover($model.showsTerminal)` 全屏盖退出回列表状态保留)。 - `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 未验证(需用户实时资源) ### 11.6 未验证(需用户实时资源)
tsnet 多节点 Up authkey)、密钥/凭据成功登录需服务器信任 key)、mosh over egress plan这些在有凭据时按 §8 流程补测 tsnet 多节点 Up authkey)、密钥/凭据成功登录需服务器信任 key)、mosh over egress plan这些在有凭据时按 §8 流程补测
@@ -162,7 +163,7 @@
| UI-1 | 设计令牌地基Nocturne chrome + blurple + JetBrains Mono | 完成 | | UI-1 | 设计令牌地基Nocturne chrome + blurple + JetBrains Mono | 完成 |
| UI-2 | 导航与多会话状态模型AppRouter/SessionManager/SessionCanvas | 完成 | | UI-2 | 导航与多会话状态模型AppRouter/SessionManager/SessionCanvas | 完成 |
| UI-3 | 终端主界面56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | 完成pane 缝拖拽待做 | | UI-3 | 终端主界面56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | 完成pane 缝拖拽待做 |
| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | 完成pane 拖拽待真机 tmux 验证 | | UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | 完成分屏功能后被用户裁决整体移除 12.3k |
| UI-5 | 设置·外观 + 键盘体系GCKeyboard 检测 + 全套 快捷键 | 待做 | | UI-5 | 设置·外观 + 键盘体系GCKeyboard 检测 + 全套 快捷键 | 待做 |
| UI-6 | 竖屏参数化 + iPhone 首版 | 待做 | | UI-6 | 竖屏参数化 + iPhone 首版 | 待做 |
@@ -174,7 +175,7 @@
### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift``SSHTerminalModel`→`TerminalSession` ### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift``SSHTerminalModel`→`TerminalSession`
- `AppRoute = .home | .terminal(UUID)``SidebarState = .collapsed|.overlay|.pinned`overlay 不占位 resizepinned 钉住各 resize 一次)。 - `AppRoute = .home | .terminal(UUID)``SidebarState = .collapsed|.overlay|.pinned`overlay 不占位 resizepinned 钉住各 resize 一次)。
- `TerminalSession` 加身份`id`/`host`/`isLive`( showsTerminal**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore传输层零改动**50 tests 保持绿)。 - `TerminalSession` 加身份`id`/`host`/`isLive`( showsTerminal**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore传输层零改动**50 tests 保持绿)。
- `SessionManager`多实例持有`open(host:)` 复用同主机会话`focus`/`minimize`/`close`前后台广播、⌘19 索引首页排序最小化置顶 + 最近活跃 - `SessionManager`多实例持有`open(host:)` **总是新建会话**2026-07-26 用户定稿会话与主机**不是一对一**再点同一主机 = 再开一条并行会话;早期"复用同主机会话"的语义已废除)、`activate(_:)`(选中既有会话:聚焦 + **已断开的自动重连**并重新判定 tmux/原生)、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。会话列表**持久显示**无论连接状态只有用户显式关闭才移除
- `ContentView` `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**详见 CLAUDE.md 摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`每会话一份后台会话也能弹安全告警)。 - `ContentView` `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**详见 CLAUDE.md 摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`每会话一份后台会话也能弹安全告警)。
- 无头验证脚手架DEBUG`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。****假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除否则 scenePhaseactive 广播经 `syncUI()` 把注入状态覆盖回 idle - 无头验证脚手架DEBUG`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。****假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除否则 scenePhaseactive 广播经 `syncUI()` 把注入状态覆盖回 idle
- **验收**四步截图取证全通过A 终端会话1 B `ui/minimize` 回首页3 会话并存 + mac-studio 最小化中置顶)→ C `ui/session/2` 切会话2独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样** - **验收**四步截图取证全通过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 实时跟随。 - **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox``onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pteaseOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。 - 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pteaseOut 先快后慢),无硬切;收起态 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 顶边现在朝 chrometopLeading 收圆角、顶描边恢复(不再 `top:-1` 推出屏外右边仍贴屏幕物理边缘不收角。pane 内容留 `TX.Space.s` 呼吸位。
- **顶带层级(一眼一个主角)**`[✕ ⌄ 独立按钮组] · 弱化标题 · [tab 组]`。按钮升到 28pt 圆角方块自成一簇(危险操作与身份信息不共容器);标题降为纯文本行(状态点 5pt + 主机名 mono12 text2 + cwd textWeak + 格子数 textFaint**当前 window title 从标题里删掉** —— 它已由 tab 组选中 chip 承担写两遍是重复tab 组是顶带唯一带容器的元素。
- **tab 组(`TmuxWindowTabGroup`**surfaceDark 圆角槽radius 11内衬 3ptwindow 是槽内段式 chip选中 = selectedBg+描边浮起,未选 = 透明贴槽底),尾部 `+` 新建 window。**坑:槽体背景必须挂在 ScrollView 的内容层** —— 挂 ScrollView 自身就是一条拖到屏幕右边的空槽ScrollView 恒占满剩余宽度。占位态controller 未 attach 但有 tmuxSummary共用 chip 本体;原生窗口不渲染 tab 组。
- 验证fixture 截图 ×3tmux 态顶带 / 原生窗口态无 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 里偷偷做) ### 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灰显占位 `mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT属传输层· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」交给 tmux relayout· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关ghostty 无运行时 mutation灰显占位
</content> </content>

View File

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