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>
This commit is contained in:
@@ -12,6 +12,11 @@
|
||||
<string>JetBrainsMono-SemiBold.ttf</string>
|
||||
<string>JetBrainsMono-Bold.ttf</string>
|
||||
</array>
|
||||
<!-- 本地网络权限(iOS 14+):SSH/mosh 直连 192.168.x.x 等 LAN 主机必须。
|
||||
没有这个键,新装/重装后系统可能不弹授权框直接静默拒绝 —— 表现为 socket 纯超时
|
||||
(2026-07-26 真机踩坑:devicectl 重装后连 LAN 主机 12s 超时,别的 app 正常)。 -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>terminalX 需要访问本地网络,才能通过 SSH/mosh 连接局域网内的服务器与设备。</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -32,13 +32,11 @@ enum SidebarState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动
|
||||
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W 快捷键与无头 URL 驱动
|
||||
/// 都要能打开它,视图内部状态触达不到。
|
||||
enum AppModal: Equatable {
|
||||
/// 关闭确认(3c)
|
||||
case closeConfirm
|
||||
/// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D)
|
||||
case splitMenu(vertical: Bool)
|
||||
}
|
||||
|
||||
/// 导航状态持有者。视图只读它、只经方法改它。
|
||||
@@ -46,13 +44,13 @@ enum AppModal: Equatable {
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published var route: AppRoute = .home
|
||||
@Published var sidebar: SidebarState = .collapsed
|
||||
/// 当前模态浮层(关闭确认 / 分屏菜单)。
|
||||
/// 当前模态浮层(关闭确认)。
|
||||
@Published var modal: AppModal?
|
||||
|
||||
/// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms easeOut,响应「减弱动态」)。
|
||||
///
|
||||
/// **路由 / 侧边栏 / 浮层的每一次变更都必须包在这条曲线里**,否则视图上的 `transition`
|
||||
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认、分屏菜单曾都是这么"啪"地跳出来的。
|
||||
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认曾都是这么"啪"地跳出来的。
|
||||
/// 收在 router 里的另一个好处:点击、⌘ 快捷键、无头 URL 三条入口天然同一手感。
|
||||
private func animated(_ change: () -> Void) {
|
||||
withAnimation(TX.Motion.standard, change)
|
||||
@@ -76,7 +74,7 @@ final class AppRouter: ObservableObject {
|
||||
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
|
||||
}
|
||||
|
||||
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
||||
/// 打开/关闭模态浮层(红点、⌘W 与无头 URL 共用同一入口)。
|
||||
func present(_ modal: AppModal) { animated { self.modal = modal } }
|
||||
func dismissModal() { animated { modal = nil } }
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ struct TerminalScreen: View {
|
||||
// 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的
|
||||
// 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起),
|
||||
// 反之展开。开合各触发一次终端 resize。
|
||||
TerminalSidebar(manager: manager, router: router, session: session,
|
||||
TerminalSidebar(manager: manager, router: router,
|
||||
tailscaleStore: tailscaleStore,
|
||||
railWidth: railWidth, safeBottom: safeBottom,
|
||||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
||||
@@ -204,7 +204,7 @@ struct TerminalScreen: View {
|
||||
stage(safeTop: safeTop, safeBottom: safeBottom)
|
||||
}
|
||||
.background(p.bgDeep)
|
||||
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
// 关闭确认(3c):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||
.overlay {
|
||||
switch router.modal {
|
||||
case .closeConfirm:
|
||||
@@ -213,13 +213,6 @@ struct TerminalScreen: View {
|
||||
onDetach: { router.dismissModal(); closeSession(kill: false) },
|
||||
onKill: { router.dismissModal(); closeSession(kill: true) },
|
||||
onCancel: { router.dismissModal() })
|
||||
case .splitMenu(let vertical):
|
||||
SplitMenuView(session: session, vertical: vertical,
|
||||
onPick: { target in
|
||||
router.dismissModal()
|
||||
performSplit(target, vertical: vertical)
|
||||
},
|
||||
onCancel: { router.dismissModal() })
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -258,47 +251,48 @@ struct TerminalScreen: View {
|
||||
// MARK: - 舞台
|
||||
|
||||
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.padding(.top, TX.Layout.capsuleInset)
|
||||
.transition(.opacity)
|
||||
}
|
||||
// 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上;
|
||||
// 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。
|
||||
paneArea
|
||||
// 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。
|
||||
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕
|
||||
// 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。
|
||||
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
|
||||
)
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
|
||||
VStack(spacing: 0) {
|
||||
// 顶带(真实布局行,用户定稿):✕/⌄ 按钮组 + 弱化标题 + tmux window tab 组,
|
||||
// 全在终端**外**的 chrome 带上 —— 终端 pane 从带下方开始,不再被悬浮胶囊遮内容。
|
||||
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
.padding(.bottom, TX.Space.s)
|
||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||
if session.tmuxUnavailable {
|
||||
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||
onDismiss: {
|
||||
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||
})
|
||||
.transition(.opacity)
|
||||
}
|
||||
capsules(safeTop: safeTop)
|
||||
paneArea
|
||||
// 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。
|
||||
.padding(.top, TX.Space.s)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(p.terminalBackground)
|
||||
.clipShape(paneShape)
|
||||
.overlay(
|
||||
paneShape.strokeBorder(p.border, lineWidth: 1)
|
||||
// 右边与屏幕边缘齐平,那条描边推出屏外:留着就是贴在屏幕边上的
|
||||
// 多余框线,还会在屏幕圆角处被切出斜口。顶边现在朝 chrome,描边保留。
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1))
|
||||
)
|
||||
// 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。
|
||||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||
.padding(.leading, TX.Layout.paneGap)
|
||||
TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom)
|
||||
}
|
||||
.background(p.bgDeep)
|
||||
}
|
||||
|
||||
/// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。
|
||||
///
|
||||
/// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 终端右侧贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||||
/// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。
|
||||
/// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条)
|
||||
/// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。
|
||||
/// 故右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、顶边(挨顶带)、
|
||||
/// 底边(挨提示条)才是真正需要圆角把 pane 和 chrome 分开的地方。
|
||||
private var paneShape: UnevenRoundedRectangle {
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: TX.Radius.pane,
|
||||
@@ -328,17 +322,6 @@ struct TerminalScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func capsules(safeTop: CGFloat) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||
Spacer(minLength: TX.Space.m)
|
||||
TerminalStatusCapsule(session: session)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||
}
|
||||
|
||||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||||
private func requestClose() {
|
||||
router.present(.closeConfirm)
|
||||
@@ -365,47 +348,11 @@ struct TerminalScreen: View {
|
||||
router.goHome()
|
||||
}
|
||||
|
||||
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
|
||||
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
|
||||
guard let tmux = session.tmuxController else { return }
|
||||
switch target {
|
||||
case .currentSession:
|
||||
tmux.splitWindow(vertical: vertical)
|
||||
case .otherSession(let name):
|
||||
tmux.splitWindow(vertical: vertical, joinSession: name)
|
||||
case .newSession:
|
||||
// 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。
|
||||
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
|
||||
case .nativeWindow:
|
||||
break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。
|
||||
private func requestSplit(vertical: Bool) {
|
||||
session.tmuxController?.listSessions()
|
||||
router.present(.splitMenu(vertical: vertical))
|
||||
}
|
||||
|
||||
/// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」——
|
||||
/// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。
|
||||
private var barContext: String {
|
||||
if !session.isLive { return session.phaseText }
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
|
||||
return session.displayName
|
||||
}
|
||||
|
||||
/// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。
|
||||
/// 底部动作(**触控可点**,不是快捷键提示)。新建 window 在原生窗口下灰显。
|
||||
/// (分屏入口已随「放弃移动端分屏」移除;左侧状态行见 `TerminalActionBar.statusLine`。)
|
||||
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()
|
||||
@@ -489,12 +436,16 @@ struct TmuxWindowView: View {
|
||||
let root = layout.root.rect
|
||||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||||
let ch = geo.size.height / CGFloat(max(1, root.height))
|
||||
let items = leaves(layout.root)
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||||
ForEach(items, id: \.pane.raw) { item in
|
||||
if let surface = window.panes[item.pane] {
|
||||
TmuxPaneView(
|
||||
surface: surface,
|
||||
isActive: window.activePane == item.pane,
|
||||
// 单 pane:surface 帧必须与容器完全一致(其实测格子是
|
||||
// 客户端尺寸的权威,见 paneViewportChanged),不留缝。
|
||||
solo: items.count == 1,
|
||||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||||
onReady: { controller.markPaneReady(item.pane) }
|
||||
)
|
||||
@@ -651,6 +602,8 @@ struct PaneSplitter: View {
|
||||
struct TmuxPaneView: View {
|
||||
@ObservedObject var surface: TmuxPaneSurface
|
||||
let isActive: Bool
|
||||
/// window 里只有这一个 pane:不留 pane 缝,surface 像素帧 == 容器帧(尺寸权威所需)。
|
||||
let solo: Bool
|
||||
let onTap: () -> Void
|
||||
let onReady: () -> Void
|
||||
@FocusState private var focused: Bool
|
||||
@@ -672,7 +625,7 @@ struct TmuxPaneView: View {
|
||||
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 }
|
||||
.task {
|
||||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||||
|
||||
@@ -5,13 +5,13 @@ import SwiftUI
|
||||
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
|
||||
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
|
||||
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 /
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / window 徽标 /
|
||||
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
|
||||
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
|
||||
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
|
||||
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
|
||||
//
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/window 行)在
|
||||
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
|
||||
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
|
||||
// `allowsHitTesting(expanded)`。
|
||||
@@ -32,8 +32,6 @@ private struct LeadingHitShape: Shape {
|
||||
struct TerminalSidebar: View {
|
||||
@ObservedObject var manager: SessionManager
|
||||
@ObservedObject var router: AppRouter
|
||||
/// 当前会话(tmux 段列它的 window)。
|
||||
@ObservedObject var session: TerminalSession
|
||||
@ObservedObject var tailscaleStore: TailscaleStore
|
||||
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||||
let railWidth: CGFloat
|
||||
@@ -56,6 +54,8 @@ struct TerminalSidebar: View {
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// 活动会话(主机连接)的切换在侧边栏;tmux window 切换已上移为终端顶部 Tab 条
|
||||
// (TmuxWindowTabGroup,用户定稿),侧边栏不再重复列 window。
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
@@ -65,9 +65,6 @@ struct TerminalSidebar: View {
|
||||
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
|
||||
sessionRows
|
||||
newHostRow
|
||||
tmuxDivider
|
||||
tmuxLabelRow
|
||||
tmuxRows
|
||||
Spacer(minLength: TX.Space.s)
|
||||
tailscaleRow
|
||||
footRow
|
||||
@@ -231,101 +228,6 @@ struct TerminalSidebar: View {
|
||||
.accessibilityLabel("连接新主机")
|
||||
}
|
||||
|
||||
// MARK: - tmux 段
|
||||
|
||||
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
|
||||
private var tmuxDivider: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
shortDivider
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.padding(.vertical, TX.Space.s)
|
||||
}
|
||||
|
||||
/// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写);
|
||||
/// 展开 = 完整分组标题(tmux · 主机 · 会话名)。
|
||||
private var tmuxLabelRow: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
|
||||
.font(TX.Font.mono(9, .semibold))
|
||||
.tracking(0.9)
|
||||
.foregroundStyle(TXAccent.mid)
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
TXSectionLabel(text: tmuxHeader)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 22)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder private var tmuxRows: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
ForEach(tmux.windows) { win in
|
||||
SidebarWindowRow(window: win,
|
||||
isSelected: win.id == tmux.activeWindowID,
|
||||
expanded: expanded, railWidth: railWidth,
|
||||
hitWidth: currentWidth) {
|
||||
tmux.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
newWindowRow { tmux.newWindow() }
|
||||
} else if let summary = session.tmuxSummary {
|
||||
// 已知是 tmux 会话但 controller 还没 attach:按概览占位,保持两态节奏稳定。
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowRowBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
|
||||
subtitle: i == 0 && summary.panes > 0
|
||||
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
|
||||
isSelected: i == 0,
|
||||
expanded: expanded, railWidth: railWidth)
|
||||
}
|
||||
newWindowRow {}
|
||||
} else {
|
||||
Text("无 tmux · 客户端窗口")
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.padding(.leading, railWidth)
|
||||
.frame(height: 32, alignment: .leading)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
|
||||
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: railWidth, height: 1)
|
||||
Text("新建 window")
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text4)
|
||||
Spacer(minLength: 0)
|
||||
Text("⌘T")
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
.padding(.trailing, TX.Space.m)
|
||||
}
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
.allowsHitTesting(expanded)
|
||||
}
|
||||
|
||||
private var tmuxHeader: String {
|
||||
let host = session.displayName
|
||||
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
|
||||
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
|
||||
}
|
||||
|
||||
// MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮)
|
||||
|
||||
/// tailnet 状态:点在图标列(两态可见),文字是明细。
|
||||
@@ -391,6 +293,7 @@ enum RailSidebarStyle {
|
||||
}
|
||||
|
||||
// MARK: - 行
|
||||
// (window 行已随「window 切换上移为顶部 Tab」移除,见 TerminalChrome.TmuxWindowTabGroup。)
|
||||
|
||||
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
|
||||
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
|
||||
@@ -471,88 +374,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +166,7 @@ final class SessionManager: ObservableObject {
|
||||
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
|
||||
let s = makeSession()
|
||||
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
|
||||
mosh: host.useMosh, tmux: spec.tmux,
|
||||
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
|
||||
mosh: host.useMosh, tmux: spec.tmux)
|
||||
// 活跃时刻错开,让首页卡片排序稳定可对拍。
|
||||
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
|
||||
sessions.append(s)
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||||
// 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在
|
||||
// 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。
|
||||
//
|
||||
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||
// 顶带层级(一眼一个主角):
|
||||
// [✕ ⌄ 独立按钮组] · 弱化标题行(纯文本,逐级降灰) · [tab 组 —— 唯一有容器感的元素]
|
||||
// tab 组 = tmux window 的段式切换槽(window 映射原生 tab 的惯例);活动会话切换在侧边栏;
|
||||
// 状态文字在底部动作条的合并状态行(见 TerminalActionBar.statusLine)。
|
||||
|
||||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||
/// 终端顶带:窗口按钮组 + 弱化标题 + tmux window tab 组。
|
||||
///
|
||||
/// **扁平**:分层交给描边和材质,投影只留一点点(`TX.Elevation.floating`)。原先那道
|
||||
/// `0 10px 30px rgba(0,0,0,.55)` 在终端文字上糊出一大团脏斑 —— 胶囊底本身是半透的,
|
||||
/// 投影一重就成了"漏墨"。底色相应提到 .92,少了投影仍然读得清。
|
||||
private struct FloatingCapsule<Content: View>: View {
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@ViewBuilder let content: Content
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.fill(p.surfaceDark.opacity(0.92))
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.strokeBorder(p.border, lineWidth: 1)
|
||||
)
|
||||
.txShadow(TX.Elevation.floating)
|
||||
}
|
||||
}
|
||||
|
||||
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。
|
||||
struct TerminalTitleCapsule: View {
|
||||
/// 与旧「标题胶囊」的三点不同(用户定稿):① ✕/⌄ **分离成独立悬浮按钮组**,不再和标题挤在
|
||||
/// 一个胶囊里 —— 危险操作(关闭)与身份信息(主机名)不该共用一个容器;② 标题**弱化**为
|
||||
/// 纯文本行(顶带在终端外,无需胶囊底衬托对比度);③ tab 组是顶带唯一带容器的元素,
|
||||
/// 视觉重心让给「切 window」这个高频动作。
|
||||
struct TerminalTopBar: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
let onClose: () -> Void
|
||||
let onMinimize: () -> Void
|
||||
@@ -41,51 +22,55 @@ struct TerminalTitleCapsule: View {
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: TX.Space.m) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮,
|
||||
// 详见 windowButton。
|
||||
windowButton(icon: "xmark", tint: TXWindowDot.close,
|
||||
label: "关闭会话", action: onClose)
|
||||
// 向下的 chevron 而不是双向内收箭头:后者在 9.5pt 下糊成一团认不出。
|
||||
// 向下同时对上设计里的「下滑最小化」手势语义。
|
||||
// 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。
|
||||
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
|
||||
label: "最小化到首页", action: onMinimize)
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 14)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 2)
|
||||
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
|
||||
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)
|
||||
}
|
||||
|
||||
/// 弱化标题行:状态点 + 主机名(text2) + 「window title · cwd」(textWeak) + 格子数(textFaint)。
|
||||
/// 主机身份已由侧边栏选中行承担,这里只是就近确认,故整行不超过 text2 的强度。
|
||||
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.text2)
|
||||
.lineLimit(1)
|
||||
if !contextLine.isEmpty {
|
||||
Text(contextLine)
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
// 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange →
|
||||
// session.objectWillChange 跟着刷新。
|
||||
Text(gridLabel)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。
|
||||
/// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。
|
||||
/// 无 tmux(没有 tab 组)时退回连接状态文案。
|
||||
private var contextLine: String {
|
||||
guard let t = session.tmuxSummary else {
|
||||
return session.isLive ? "" : session.phaseText
|
||||
}
|
||||
var parts: [String] = []
|
||||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||||
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
|
||||
return parts.joined(separator: " · ")
|
||||
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
|
||||
}
|
||||
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。
|
||||
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法)。
|
||||
static func abbreviate(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
|
||||
@@ -99,31 +84,29 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
|
||||
private var gridLabel: String {
|
||||
// tmux 模式读 controller 的客户端格子(raw 的 screenGrid 随 RawTerminalView 卸载而冻结,
|
||||
// 侧边栏开合后会显示旧值);raw 模式仍读 surface 实报的 screenGrid。
|
||||
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
let g = session.screenGrid.value
|
||||
return "\(g.cols)×\(g.rows)"
|
||||
}
|
||||
|
||||
/// 关闭 / 最小化按钮。
|
||||
///
|
||||
/// 原来是 macOS 交通灯式的 13pt 纯色圆点,三处不合适:① 纯色块**不说明自己是干什么的** ——
|
||||
/// mac 上靠 hover 才浮出字形,触屏没有 hover,于是永远只是两个色点;② 13pt 命中目标在
|
||||
/// 触屏上太小,而"关闭"误触代价很高;③ 圆点是 mac 窗口的语汇,与本 app 通篇的圆角矩形
|
||||
/// 控件语汇不搭。
|
||||
///
|
||||
/// 现在:22pt 圆角方块 + **常显字形**(`✕` 关闭 / `⌄` 收下去 = 缩回首页卡片),颜色语义保留
|
||||
/// (红=关闭、琥珀=最小化),底色只上 14% 不铺满 —— 与徽标/控件同一套形状语言,命中 26×34。
|
||||
/// 关闭 / 最小化按钮:28pt 圆角方块 + **常显字形**(触屏没有 hover,纯色点说明不了自己),
|
||||
/// 颜色语义保留(红=关闭、琥珀=最小化),底色只上 14% —— 与全 app 的圆角矩形控件同语汇。
|
||||
private func windowButton(icon: String, tint: Color, label: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 9.5, weight: .bold))
|
||||
.font(.system(size: 10.5, weight: .bold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
|
||||
.frame(width: 28, height: 28)
|
||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 8).strokeBorder(tint.opacity(0.45), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 26, height: 34)
|
||||
.frame(width: 30, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
@@ -131,84 +114,123 @@ struct TerminalTitleCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。
|
||||
/// tmux window 的 **tab 组**:一条 surfaceDark 圆角槽,window 是槽内的段式 chip。
|
||||
///
|
||||
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格
|
||||
/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。
|
||||
struct TerminalStatusCapsule: View {
|
||||
/// 「组」的语义:这些 tab 同属当前 tmux 会话 —— 容器即分组,不再各自悬浮。选中 chip 用
|
||||
/// accent 底+描边浮起,未选 chip 透明贴槽底;尾部 `+` 新建 window(浏览器 tab 组惯例)。
|
||||
/// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。
|
||||
struct TmuxWindowTabGroup: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
|
||||
var body: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
WindowTabGroupRow(controller: tmux)
|
||||
} else if let summary = session.tmuxSummary {
|
||||
TabGroupSlot {
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowTabChipBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty
|
||||
? summary.currentTitle : "window \(i)",
|
||||
isSelected: i == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。
|
||||
private struct WindowTabGroupRow: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
FloatingCapsule {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(transportText, color: transportColor, dot: false)
|
||||
separator
|
||||
cell(tmuxText, color: p.text2, dot: false)
|
||||
TabGroupSlot {
|
||||
ForEach(controller.windows) { win in
|
||||
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
|
||||
controller.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
// 组内 `+`:新建 window 的就近入口(底部动作条的大按钮仍在)。
|
||||
Button { controller.newWindow() } label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(p.text4)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel("新建 window")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var separator: some View {
|
||||
Rectangle().fill(p.border).frame(width: 1, height: 16)
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
/// tab 组的槽体:内衬 3pt,chip 挤在同一条容器里 —— 「同组」的视觉本体。条目多时横向滚动。
|
||||
private struct TabGroupSlot<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
// 槽体(底+描边)挂在**内容层**而不是 ScrollView 上:ScrollView 恒占满剩余宽度,
|
||||
// 挂它身上就是一条拖到屏幕边的空槽 —— 槽必须紧贴 chip 才读作「一组」。
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 2) { content }
|
||||
.padding(3)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||
.strokeBorder(p.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
|
||||
/// 单个 window chip(title/index 是 window 自己的 @Published,须直接订阅才会刷新)。
|
||||
private struct WindowTabChip: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
|
||||
}
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel("切换到 window \(window.index)")
|
||||
}
|
||||
}
|
||||
|
||||
/// chip 的视觉本体(真实 window 与占位共用):`编号 标题`,选中浮起、未选贴槽底。
|
||||
private struct WindowTabChipBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
if dot { TXStatusDot(color: color, size: 6) }
|
||||
Text(text)
|
||||
.font(TX.Font.mono(12, .medium))
|
||||
.foregroundStyle(color)
|
||||
Text(index)
|
||||
.font(TX.Font.mono(10.5, .semibold))
|
||||
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
|
||||
Text(title)
|
||||
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? p.text : p.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第一格:网络可达性(tsnet 出口 / 直连)
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "direct"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting: "tsnet"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:传输层(mosh / SSH)
|
||||
private var transportText: String {
|
||||
if case .reconnecting(let n) = session.link {
|
||||
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
|
||||
}
|
||||
return session.moshEngaged ? "mosh" : "ssh"
|
||||
}
|
||||
|
||||
private var transportColor: Color {
|
||||
if case .reconnecting = session.link { return p.reconnecting }
|
||||
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
|
||||
return p.text2
|
||||
}
|
||||
|
||||
// 第三格:会话复用层
|
||||
private var tmuxText: String {
|
||||
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : "—"
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 28)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.capsule - 3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.capsule - 3)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,17 +424,22 @@ struct TmuxInstallSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 舞台底部动作条。
|
||||
/// 舞台底部动作条 + **左下状态行**。
|
||||
///
|
||||
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「竖分屏 / 新 window / 回首页」
|
||||
/// 只写成 `⌘D` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
|
||||
/// 左下是合并后的**唯一**状态出口(原右上状态胶囊的三格分层徽标 + 原左下上下文文字,
|
||||
/// 二者信息重复,用户裁决合并到这里):`[dot 链路] | 传输 | 会话概览`。
|
||||
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀/红;重连中 → 第二格转琥珀;
|
||||
/// tmux 缺失 → 第三格写「无 tmux · 客户端窗口」而非报错。主机名不再重复出现
|
||||
/// (标题胶囊与当前 tab 已有)。
|
||||
///
|
||||
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「新 window / 回首页」
|
||||
/// 只写成 `⌘T` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
|
||||
/// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。
|
||||
///
|
||||
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮
|
||||
/// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。)
|
||||
struct TerminalActionBar: View {
|
||||
/// 左侧上下文说明(会话/链路状态一句话)。
|
||||
let context: String
|
||||
@ObservedObject var session: TerminalSession
|
||||
let actions: [Action]
|
||||
let safeBottom: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@@ -423,20 +450,17 @@ struct TerminalActionBar: View {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
/// 不可用时灰显但仍在(如非 tmux 会话不能分屏)——不隐藏,免得布局跳。
|
||||
/// 不可用时灰显但仍在(如原生窗口不能新建 window)——不隐藏,免得布局跳。
|
||||
var enabled: Bool = true
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
/// 窄屏只留前两个:四个在 iPhone 上放不下,会折行把 34pt 的带撑破。
|
||||
/// 窄屏只留前两个:多了在 iPhone 上放不下,会折行把 34pt 的带撑破。
|
||||
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Text(context)
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
statusLine
|
||||
Spacer(minLength: TX.Space.s)
|
||||
ForEach(shown) { action in
|
||||
Button(action: action.run) {
|
||||
@@ -465,6 +489,80 @@ struct TerminalActionBar: View {
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: TX.Layout.footBand(safeBottom))
|
||||
.animation(TX.Motion.standard, value: context)
|
||||
.animation(TX.Motion.standard, value: contextText)
|
||||
}
|
||||
|
||||
// MARK: - 状态行(原右上状态胶囊与旧左下文字的合并体)
|
||||
|
||||
private var statusLine: some View {
|
||||
HStack(spacing: 0) {
|
||||
cell(egressText, color: egressColor, dot: true)
|
||||
separator
|
||||
cell(transportText, color: transportColor, dot: false)
|
||||
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 出口 / 直连)
|
||||
private var egressText: String {
|
||||
switch session.link {
|
||||
case .relay: "tsnet"
|
||||
case .direct: "direct"
|
||||
case .connecting: "连接中"
|
||||
case .reconnecting: "tsnet"
|
||||
case .offline: "离线"
|
||||
}
|
||||
}
|
||||
|
||||
private var egressColor: Color {
|
||||
switch session.link {
|
||||
case .direct, .relay: p.online
|
||||
case .connecting: p.text4
|
||||
case .reconnecting: p.reconnecting
|
||||
case .offline: p.offline
|
||||
}
|
||||
}
|
||||
|
||||
// 第二格:传输层(mosh / SSH)
|
||||
private var transportText: String {
|
||||
if case .reconnecting(let n) = session.link {
|
||||
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
|
||||
}
|
||||
return session.moshEngaged ? "mosh" : "ssh"
|
||||
}
|
||||
|
||||
private var transportColor: Color {
|
||||
if case .reconnecting = session.link { return p.reconnecting }
|
||||
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
|
||||
return p.text3
|
||||
}
|
||||
|
||||
// 第三格:会话复用层概览(旧左下「会话状态一句话」并入这里,windows 数保留)。
|
||||
private var contextText: String {
|
||||
if let t = session.tmuxSummary {
|
||||
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
|
||||
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
|
||||
}
|
||||
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
|
||||
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
|
||||
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
|
||||
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
|
||||
return session.isLive ? "检测 tmux…" : session.phaseText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||||
// 设计稿 `3c 关闭确认`。(`3b 新建分屏菜单` 已随「放弃移动端分屏」移除。)
|
||||
//
|
||||
// 语义分工(务必别混):
|
||||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||||
@@ -137,148 +137,3 @@ struct CloseConfirmDialog: View {
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 新建分屏菜单(3b)
|
||||
|
||||
/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」:
|
||||
/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。
|
||||
///
|
||||
/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。
|
||||
struct SplitMenuView: View {
|
||||
@ObservedObject var session: TerminalSession
|
||||
/// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D)
|
||||
let vertical: Bool
|
||||
let onPick: (SplitTarget) -> Void
|
||||
let onCancel: () -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
enum SplitTarget {
|
||||
case currentSession
|
||||
case otherSession(String)
|
||||
case newSession
|
||||
/// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。
|
||||
case nativeWindow
|
||||
}
|
||||
|
||||
/// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端
|
||||
/// attach 着其它会话时会把它们错误地排除掉。)
|
||||
private var detached: [TmuxSessionInfo] {
|
||||
let current = session.tmuxSummary?.name ?? ""
|
||||
return session.tmuxSessionList.filter { $0.name != current }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TXModalScrim(onDismiss: onCancel) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Rectangle().fill(p.divider).frame(height: 1)
|
||||
if session.tmuxSummary != nil {
|
||||
TXSectionLabel(text: "tmux 会话")
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.padding(.top, TX.Space.s)
|
||||
.padding(.bottom, 2)
|
||||
row(title: currentName, detail: currentDetail, dot: p.online,
|
||||
tag: "当前", selected: true) { onPick(.currentSession) }
|
||||
ForEach(detached) { s in
|
||||
// detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。
|
||||
row(title: s.name,
|
||||
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
|
||||
dot: p.online,
|
||||
tag: s.lastActivity.map { SessionCard.relative($0) }) {
|
||||
onPick(.otherSession(s.name))
|
||||
}
|
||||
}
|
||||
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
|
||||
onPick(.newSession)
|
||||
}
|
||||
Rectangle().fill(p.divider).frame(height: 1)
|
||||
.padding(.vertical, TX.Space.xs)
|
||||
}
|
||||
// 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。
|
||||
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
|
||||
dot: nil, tag: nil, disabled: true) {}
|
||||
}
|
||||
.padding(.bottom, TX.Space.xs)
|
||||
.frame(width: 360)
|
||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
|
||||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
||||
)
|
||||
.txShadow(TX.Elevation.dialog)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text(vertical ? "竖分屏到…" : "横分屏到…")
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Spacer()
|
||||
Text(session.displayName)
|
||||
.font(TX.Font.mono(11))
|
||||
.foregroundStyle(TXAccent.mid)
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
private var currentName: String {
|
||||
guard let t = session.tmuxSummary else { return session.displayName }
|
||||
return t.name.isEmpty ? "当前会话" : t.name
|
||||
}
|
||||
|
||||
private var currentDetail: String {
|
||||
guard let t = session.tmuxSummary else { return "" }
|
||||
var parts: [String] = []
|
||||
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
|
||||
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
|
||||
if t.panes > 0 { parts.append("\(t.panes) panes") }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。
|
||||
private func row(title: String, detail: String?, dot: Color?, tag: String?,
|
||||
selected: Bool = false, disabled: Bool = false,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
ZStack {
|
||||
if let dot { TXStatusDot(color: dot, size: 7) }
|
||||
}
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(TX.Font.mono(13, .medium))
|
||||
.foregroundStyle(disabled ? p.textWeak : p.text)
|
||||
if let detail, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: TX.Space.xs)
|
||||
if let tag {
|
||||
Text(tag)
|
||||
.font(TX.Font.mono(10.5, .medium))
|
||||
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, TX.Space.s)
|
||||
.frame(height: 40)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,14 +225,6 @@ final class TerminalSession: ObservableObject, Identifiable {
|
||||
|
||||
/// 假会话注入的 tmux 概览(真实会话走 `tmuxSummary` 的派生分支)。
|
||||
private var injectedTmuxSummary: TmuxSummary?
|
||||
/// 假会话注入的 tmux 会话列表(分屏菜单的 detached 行;真实会话走 controller.sessions)。
|
||||
private(set) var injectedTmuxSessions: [TmuxSessionInfo] = []
|
||||
|
||||
/// 分屏菜单的候选会话:真实会话用 controller 的 list-sessions 结果,假会话用注入值。
|
||||
var tmuxSessionList: [TmuxSessionInfo] {
|
||||
if let c = tmuxController, !c.sessions.isEmpty { return c.sessions }
|
||||
return injectedTmuxSessions
|
||||
}
|
||||
|
||||
/// tmux 概览:假会话用注入值,真实会话从 controller 现算。
|
||||
var tmuxSummary: TmuxSummary? {
|
||||
@@ -579,10 +571,9 @@ final class TerminalSession: ObservableObject, Identifiable {
|
||||
/// 只为让首页会话卡/轨道头像/状态徽标有内容可截图对拍(见 UIPreviewFixture)。
|
||||
/// 走 `gate` 而非 `session.receive`,故 surface 未 attach 时会先缓冲、就绪后 flush。
|
||||
func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool,
|
||||
tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) {
|
||||
tmux: TmuxSummary? = nil) {
|
||||
isSyntheticFixture = true
|
||||
injectedTmuxSummary = tmux
|
||||
injectedTmuxSessions = tmuxSessions
|
||||
self.host = host
|
||||
connectionTitle = host.name
|
||||
isLive = true
|
||||
|
||||
@@ -293,8 +293,6 @@ enum TX {
|
||||
static let paneDragHit: CGFloat = 12
|
||||
/// 触控命中区下限
|
||||
static let minTouch: CGFloat = 44
|
||||
/// 终端主 pane 顶部给浮起胶囊预留
|
||||
static let capsuleInset: CGFloat = 86
|
||||
/// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条
|
||||
/// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区
|
||||
/// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。
|
||||
|
||||
@@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
|
||||
init(id: TmuxPaneID, cols: Int, rows: Int,
|
||||
onInput: @escaping @Sendable (Data) -> Void,
|
||||
onMetrics: @escaping @Sendable (Int, Int) -> Void) {
|
||||
onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
|
||||
self.id = id
|
||||
self.grid = (cols, rows)
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
|
||||
// tmux 模式:surface resize 只回报 cell 像素供换算,绝不反向驱动 tmux(防反馈环)。
|
||||
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
// tmux 模式:surface resize 上报**真实格子 + cell 像素**(cols/rows/cellW/cellH)。
|
||||
// controller 只在「单 pane window」时把它当客户端尺寸的权威(见 paneViewportChanged),
|
||||
// 多 pane 下仅刷新 cell 缓存 —— 依旧不构成反馈环。
|
||||
resize: { vp in onViewport(Int(vp.columns), Int(vp.rows),
|
||||
Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
@@ -54,16 +57,6 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。
|
||||
struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let windows: Int
|
||||
/// 是否已被某客户端 attach(当前会话即 attached 的那个)。
|
||||
let isAttached: Bool
|
||||
let lastActivity: Date?
|
||||
}
|
||||
|
||||
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
|
||||
/// 尺寸走"路线 b":app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取
|
||||
/// layout rect(%output 按 tmux pane 宽高排版,二者一致才不换行/清屏错乱)。
|
||||
@@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
final class TmuxController: ObservableObject {
|
||||
@Published private(set) var windows: [TmuxWindow] = []
|
||||
@Published var activeWindowID: TmuxWindowID?
|
||||
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
|
||||
@Published private(set) var sessions: [TmuxSessionInfo] = []
|
||||
|
||||
private let parser = TmuxControlParser()
|
||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||
@@ -82,6 +73,8 @@ final class TmuxController: ObservableObject {
|
||||
var onExit: (() -> Void)?
|
||||
|
||||
private var clientCols = 80, clientRows = 24
|
||||
/// 客户端格子对外快照(顶栏胶囊显示用 —— tmux 模式下 raw 的 screenGrid 已随 RawTerminalView 卸载冻结)。
|
||||
@Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0)
|
||||
private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子)
|
||||
private var lastSentCols = 0, lastSentRows = 0
|
||||
private var resizeDebounce: Task<Void, Never>?
|
||||
@@ -89,12 +82,11 @@ final class TmuxController: ObservableObject {
|
||||
// control-mode 命令应答 FIFO 匹配。
|
||||
private var attachAcked = false
|
||||
private enum PendingKind: CustomStringConvertible {
|
||||
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
|
||||
case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ignore: "ignore"
|
||||
case .listWindows: "listWindows"
|
||||
case .listSessions: "listSessions"
|
||||
case .currentSession: "currentSession"
|
||||
case .capturePane(let p): "capturePane(%\(p.raw))"
|
||||
}
|
||||
@@ -122,8 +114,9 @@ final class TmuxController: ObservableObject {
|
||||
private func kickoffIfReady() {
|
||||
guard attachAcked, containerKnown, !kickoffDone else { return }
|
||||
kickoffDone = true
|
||||
lastSentCols = clientCols; lastSentRows = clientRows // 后续实测同值时不再重发
|
||||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface 实测格子(非像素换算)
|
||||
pending.append(.ignore)
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
|
||||
@@ -132,30 +125,64 @@ final class TmuxController: ObservableObject {
|
||||
// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。
|
||||
sendCommand("display-message -p \"#{session_name}\"")
|
||||
pending.append(.currentSession)
|
||||
listSessions() // 分屏菜单要列全部会话
|
||||
}
|
||||
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
||||
func setClientSize(cols: Int, rows: Int) {
|
||||
clientCols = max(1, cols); clientRows = max(1, rows)
|
||||
clientGrid = (clientCols, clientRows)
|
||||
}
|
||||
|
||||
/// cell 设备像素(来自 raw 终端或 pane surface metrics)。
|
||||
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
|
||||
/// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。
|
||||
func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) }
|
||||
|
||||
/// pane surface 的真实格子上报 —— **客户端尺寸的权威来源**。
|
||||
///
|
||||
/// 像素除法(containerResized)复刻不了 ghostty 内部的取整/内边距:真机与模拟器实测
|
||||
/// 「容器 px ÷ 整数 cell px」比 surface 真实格子**偏大 1 行/列**(如算得 164×51、真实 163×50)。
|
||||
/// 偏大意味着远端按更宽的行排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,
|
||||
/// 全屏 TUI(Claude Code 等)重绘几轮就积累成行首碎片(真机花屏根因)。
|
||||
/// 单 pane window 的 pane 帧恒等于容器帧,其 ghostty 实测格子就是客户端应有的格子 ——
|
||||
/// 用它发 refresh-client。帧尺寸只由容器决定、不受 tmux 布局影响,故没有反馈环。
|
||||
func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||
setCellPixels(w: cellW, h: cellH)
|
||||
guard kickoffDone, cols > 0, rows > 0 else { return }
|
||||
// 多 pane window 的 pane 帧只是容器的比例切片,格子代表不了客户端整体 → 只刷 cell 缓存。
|
||||
guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return }
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, rows)
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 120_000_000) // 合并侧边栏动画期间的连续变化
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)")
|
||||
self.sendCommand("refresh-client -C \(cols)x\(rows)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。
|
||||
/// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。
|
||||
/// 尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",**仅作兜底**:有单 pane window 在场时
|
||||
/// 让位给 paneViewportChanged 的实测格子(像素除法会偏大 1 行/列,见其注释)。
|
||||
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
|
||||
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
|
||||
containerKnown = true
|
||||
if !kickoffDone {
|
||||
// kickoff 初值不用像素换算覆盖 —— setClientSize 已带 raw surface 的真实格子
|
||||
// (attach 前 RawTerminalView 与 TmuxStage 同槽同尺寸,ghostty 实测值可直接沿用)。
|
||||
NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)")
|
||||
kickoffIfReady()
|
||||
return
|
||||
}
|
||||
if windows.contains(where: { $0.panes.count == 1 }) { return } // 实测路径接管
|
||||
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
|
||||
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, rows)
|
||||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
|
||||
// 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。
|
||||
containerKnown = true
|
||||
if !kickoffDone { kickoffIfReady(); return }
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
||||
@@ -256,7 +283,9 @@ final class TmuxController: ObservableObject {
|
||||
let surface = TmuxPaneSurface(
|
||||
id: p, cols: cols, rows: rows,
|
||||
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
|
||||
onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } }
|
||||
onViewport: { [weak self] c, r, cw, ch in
|
||||
Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) }
|
||||
}
|
||||
)
|
||||
surfaceByPane[p] = surface
|
||||
win.panes[p] = surface
|
||||
@@ -293,22 +322,6 @@ final class TmuxController: ObservableObject {
|
||||
if parts[2] == "1" { activeWindowID = w }
|
||||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||||
}
|
||||
case .listSessions:
|
||||
sessions = r.lines.compactMap { line in
|
||||
let f = line.components(separatedBy: Self.fieldSep)
|
||||
guard f.count >= 3 else { return nil }
|
||||
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
|
||||
return TmuxSessionInfo(name: f[0],
|
||||
windows: Int(f[1]) ?? 1,
|
||||
isAttached: f[2] == "1",
|
||||
lastActivity: activity)
|
||||
}
|
||||
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
NSLog("TMUXDBG currentSession=\(name)")
|
||||
}
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
@@ -379,7 +392,10 @@ final class TmuxController: ObservableObject {
|
||||
func newWindow() { sendCommand("new-window") }
|
||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||
|
||||
// MARK: - 关闭 / 分屏 / resize(UI-4)
|
||||
// MARK: - 关闭 / resize(UI-4)
|
||||
|
||||
// 注:app 内主动分屏(split-window / join-pane)已随「放弃移动端分屏」整体移除;
|
||||
// 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。
|
||||
|
||||
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
|
||||
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
|
||||
@@ -388,24 +404,6 @@ final class TmuxController: ObservableObject {
|
||||
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
|
||||
func killSession() { sendCommand("kill-session") }
|
||||
|
||||
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
|
||||
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
|
||||
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
|
||||
func splitWindow(vertical: Bool, joinSession: String? = nil) {
|
||||
if let s = joinSession {
|
||||
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
|
||||
} else {
|
||||
sendCommand("split-window \(vertical ? "-h" : "-v")")
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
|
||||
func splitIntoNewSession(vertical: Bool, name: String) {
|
||||
sendCommand("new-session -d -s \(name)")
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
|
||||
}
|
||||
|
||||
/// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。
|
||||
/// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。
|
||||
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
|
||||
@@ -413,13 +411,6 @@ final class TmuxController: ObservableObject {
|
||||
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
|
||||
}
|
||||
|
||||
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
|
||||
func listSessions() {
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
|
||||
pending.append(.listSessions)
|
||||
}
|
||||
|
||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||
|
||||
|
||||
@@ -150,18 +150,6 @@ enum UIPreviewFixture {
|
||||
]),
|
||||
]
|
||||
|
||||
/// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。
|
||||
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
|
||||
// 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。
|
||||
[
|
||||
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
|
||||
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
|
||||
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
|
||||
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
|
||||
lastActivity: Date(timeIntervalSinceNow: -86400)),
|
||||
]
|
||||
}
|
||||
|
||||
/// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。
|
||||
static func hostFromLaunchArgs() -> SavedHost? {
|
||||
let d = UserDefaults.standard
|
||||
@@ -186,8 +174,7 @@ enum UIPreviewFixture {
|
||||
/// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。
|
||||
/// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。
|
||||
///
|
||||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h`
|
||||
/// · `split-now/v|h`(直接分屏) · `resize/<cols>`(resize-pane)
|
||||
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `resize/<cols>`(resize-pane)
|
||||
/// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器)
|
||||
/// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin`
|
||||
@MainActor
|
||||
@@ -204,16 +191,8 @@ enum UIPreviewFixture {
|
||||
case "close-now":
|
||||
// 跳过确认直接关(回归脚本用)。
|
||||
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
|
||||
case "split":
|
||||
guard router.route.sessionID != nil else { return false }
|
||||
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
|
||||
case "dismiss":
|
||||
router.dismissModal()
|
||||
case "split-now":
|
||||
// 跳过菜单直接对当前会话分屏(验证 split-window 链路)。
|
||||
guard let id = router.route.sessionID, let s = manager.session(id),
|
||||
let tmux = s.tmuxController else { return false }
|
||||
tmux.splitWindow(vertical: path.dropFirst().first != "h")
|
||||
case "pick":
|
||||
// 会话选择器:attach 指定会话(`ui/pick/<name>`)。此时通常还在首页,故优先取待选择的会话。
|
||||
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),
|
||||
|
||||
Reference in New Issue
Block a user