按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
660 lines
31 KiB
Swift
660 lines
31 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import GhosttyTerminal
|
||
import TXCore
|
||
|
||
struct ContentView: View {
|
||
@StateObject private var manager = SessionManager()
|
||
@StateObject private var router = AppRouter()
|
||
@StateObject private var hostStore = HostStore()
|
||
@StateObject private var credStore = FileCredentialStore()
|
||
@StateObject private var tailscaleStore = TailscaleStore()
|
||
@StateObject private var theme = TXThemeManager()
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
|
||
var body: some View {
|
||
Group {
|
||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||
TsnetProbeView(authKey: tsnetKey)
|
||
} else {
|
||
RootView(manager: manager, router: router, hostStore: hostStore,
|
||
credStore: credStore, tailscaleStore: tailscaleStore)
|
||
}
|
||
}
|
||
.environmentObject(theme)
|
||
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
|
||
.onChange(of: theme.palette.id) { _, _ in
|
||
// 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给所有会话的 surface。
|
||
manager.applyTerminalTheme(theme.palette.terminalTheme)
|
||
}
|
||
.onAppear {
|
||
manager.bind(credentials: credStore, tailscale: tailscaleStore)
|
||
#if DEBUG
|
||
UIPreviewFixture.forceLandscapeIfRequested()
|
||
// 无头 UI 验证:造假会话+假主机供截图对拍(不建连接、不写盘),与 -txHost 真实连接互斥。
|
||
if UIPreviewFixture.isEnabled {
|
||
hostStore.injectFixtureHosts(UIPreviewFixture.hosts)
|
||
tailscaleStore.injectFixtureConnections(UIPreviewFixture.tailnets)
|
||
if let s = manager.spawnFixtureSessions() { router.enter(s.id) }
|
||
return
|
||
}
|
||
#endif
|
||
#if DEBUG
|
||
// 无头验证**真实的「首页点主机」路径**(`-txOpenAsHost 1`):把启动参数造成一台主机塞进
|
||
// 内存主机表,再走 manager.open(host:) —— 即探测 tmux → 弹选择器 → 选完才进终端。
|
||
// 与 `-txHost` 的老路径(autoConnectIfConfigured:直连 + 立刻进终端)互斥。
|
||
if UserDefaults.standard.bool(forKey: "txOpenAsHost"),
|
||
let h = UIPreviewFixture.hostFromLaunchArgs() {
|
||
hostStore.injectFixtureHosts([h])
|
||
manager.open(host: h)
|
||
return
|
||
}
|
||
#endif
|
||
// 无头验证:启动参数带 -txHost 时建会话并直接进终端(老路径,保留给回归脚本)。
|
||
if let s = manager.autoConnectIfConfigured() { router.enter(s.id) }
|
||
}
|
||
.onChange(of: scenePhase) { _, phase in
|
||
switch phase {
|
||
case .background: manager.enterBackground()
|
||
case .active: manager.enterForeground()
|
||
default: break
|
||
}
|
||
}
|
||
#if DEBUG
|
||
// 无头验证:terminalx://ui/<动作> 驱动导航(见 UIPreviewFixture.handle)。
|
||
// 非 ui/ 前缀的 URL(如 terminalx://resume)只用于把 app 拉回前台,无需处理。
|
||
.onOpenURL { url in
|
||
guard url.host == "ui" else { return }
|
||
let parts = url.pathComponents.filter { $0 != "/" }
|
||
_ = UIPreviewFixture.handle(uiCommand: parts, manager: manager, router: router)
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
/// 顶层壳:会话画布常驻在底,首页盖在其上。
|
||
///
|
||
/// **为什么不用 fullScreenCover**:会话必须在离开终端后继续跑并保留内容,且「最小化缩回首页卡片」
|
||
/// 需要 matchedGeometry —— 那要求两端在同一视图层级,独立 presentation 层做不到。
|
||
struct RootView: 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
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
SessionCanvas(manager: manager, router: router,
|
||
hostStore: hostStore, credStore: credStore, tailscaleStore: tailscaleStore)
|
||
if router.route == .home {
|
||
HomeView(manager: manager, router: router, hostStore: hostStore,
|
||
credStore: credStore, tailscaleStore: tailscaleStore)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.background(p.bgDeep.ignoresSafeArea())
|
||
// 沉浸只在终端页:设计稿 1b/3a 顶部就是胶囊/logo(无状态栏),1e 首页与 1g 设置有状态栏。
|
||
.statusBarHidden(router.route != .home)
|
||
// 连接流程的一部分:探测出 tmux 会话后在**首页上**弹选择器,选完才进终端。
|
||
// 挂在 RootView 而不是终端页 —— 用户点主机时还没进终端。
|
||
.sheet(isPresented: Binding(
|
||
get: { manager.sessionAwaitingChoice != nil },
|
||
set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } }
|
||
)) {
|
||
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
|
||
SessionPickerSheet(
|
||
host: s.displayName,
|
||
choice: choice,
|
||
onAttach: { s.attachTmuxSession($0) },
|
||
onCreate: { s.createTmuxSession($0) },
|
||
onNative: { s.useNativeTerminal() })
|
||
.environmentObject(theme)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.hidden)
|
||
}
|
||
}
|
||
// 会话就绪(选择完成 / 无 tmux / 失败)→ 进终端页。
|
||
.onChange(of: manager.enterRequest) { _, id in
|
||
guard let id else { return }
|
||
manager.enterRequest = nil
|
||
router.enter(id)
|
||
}
|
||
// 兜底:路由指向的会话已不存在(被关闭/清理)→ 回首页,而不是停在空画布上黑屏。
|
||
.onChange(of: manager.sessions.count) { _, _ in
|
||
if let id = router.route.sessionID, manager.session(id) == nil { router.goHome() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 所有会话的终端视图**常驻挂载**,只有当前路由指向的那条可见。
|
||
///
|
||
/// 这是"回首页/切会话内容不丢"的根:GhosttyKit 的 surface 一旦从视图树摘除就释放 grid,
|
||
/// 故沿用 tmux 切 tab 已验证的手法——ZStack 全挂载 + opacity 切换 + 关掉非活动者的命中测试。
|
||
/// 画布尺寸恒为全屏,故切换路由**不会触发 PTY resize**。
|
||
struct SessionCanvas: View {
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var hostStore: HostStore
|
||
@ObservedObject var credStore: FileCredentialStore
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
ForEach(manager.sessions) { session in
|
||
let visible = router.route.sessionID == session.id
|
||
TerminalScreen(session: session, manager: manager, router: router,
|
||
tailscaleStore: tailscaleStore)
|
||
.opacity(visible ? 1 : 0)
|
||
// 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。
|
||
// `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**,
|
||
// 故不能用 frame 动画替代(那会让 tmux 反复重排)。
|
||
.scaleEffect(visible ? 1 : 0.97)
|
||
.animation(AppRouter.sidebarAnimation, value: visible)
|
||
.allowsHitTesting(visible)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`)。
|
||
///
|
||
/// 布局:左 56pt 轨道(或 Pin 态的 288pt 面板)+ 右舞台。舞台顶部留 86pt 预留带给浮起胶囊,
|
||
/// 底部 34pt 提示条。遮罩态的面板是**浮层**,盖住轨道、不参与布局 → 终端不 reflow、不 resize;
|
||
/// 只有 Pin 切换才改变舞台宽度(各 resize 一次,符合设计约束)。
|
||
struct TerminalScreen: View {
|
||
@ObservedObject var session: TerminalSession
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@State private var sidebarSearch = ""
|
||
/// 「安装并启用 tmux」引导弹层。
|
||
@State private var showTmuxInstall = false
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 0) {
|
||
if router.sidebar == .pinned {
|
||
sidebarPanel
|
||
} else {
|
||
RailView(manager: manager, router: router, session: session,
|
||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost)
|
||
}
|
||
stage
|
||
}
|
||
.background(p.bgDeep.ignoresSafeArea())
|
||
// 沉浸:内容顶到屏幕边缘(设计稿 1b/3a 顶部就是胶囊/logo)。状态栏的显隐归 RootView
|
||
// 统一控制 —— 本视图常驻挂载(SessionCanvas),在这里 hide 会连首页一起隐藏。
|
||
.ignoresSafeArea(edges: .top)
|
||
// 遮罩态:面板浮在最上层盖住轨道,其余压 50% 遮罩,点终端任意处收起。
|
||
// 动效:遮罩淡入 + 面板从左滑入(200–260ms easeOut,见 AppRouter 的动画包裹)。
|
||
.overlay(alignment: .leading) {
|
||
if router.sidebar == .overlay {
|
||
ZStack(alignment: .leading) {
|
||
Color(hex: 0x0A0B12).opacity(0.5)
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { router.dismissOverlaySidebar() }
|
||
.transition(.opacity)
|
||
sidebarPanel
|
||
.transition(.move(edge: .leading))
|
||
}
|
||
// 浮层要顶到屏幕边缘(面板是全高的),否则被 safe area 推低。
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||
.overlay {
|
||
switch router.modal {
|
||
case .closeConfirm:
|
||
CloseConfirmDialog(
|
||
session: session,
|
||
onDetach: { router.dismissModal(); closeSession(kill: false) },
|
||
onKill: { router.dismissModal(); closeSession(kill: true) },
|
||
onCancel: { router.dismissModal() })
|
||
case .splitMenu(let vertical):
|
||
SplitMenuView(session: session, vertical: vertical,
|
||
onPick: { target in
|
||
router.dismissModal()
|
||
performSplit(target, vertical: vertical)
|
||
},
|
||
onCancel: { router.dismissModal() })
|
||
case nil:
|
||
EmptyView()
|
||
}
|
||
if showTmuxInstall {
|
||
TmuxInstallSheet(
|
||
onRun: { cmd in
|
||
showTmuxInstall = false
|
||
session.runInTerminal(cmd)
|
||
},
|
||
onCancel: { showTmuxInstall = false })
|
||
}
|
||
}
|
||
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
|
||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||
get: { session.hostKeyMismatch != nil },
|
||
set: { if !$0 { session.dismissHostKeyMismatch() } }
|
||
), presenting: session.hostKeyMismatch) { _ in
|
||
Button("信任新密钥", role: .destructive) { session.trustNewHostKey() }
|
||
Button("取消", role: .cancel) { session.dismissHostKeyMismatch() }
|
||
} message: { info in
|
||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||
}
|
||
}
|
||
|
||
private var sidebarPanel: SidebarPanel {
|
||
SidebarPanel(manager: manager, router: router, session: session,
|
||
tailscaleStore: tailscaleStore,
|
||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
||
search: $sidebarSearch)
|
||
}
|
||
|
||
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
|
||
/// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。
|
||
private func goHomeToAddHost() {
|
||
router.goHome()
|
||
}
|
||
|
||
// MARK: - 舞台
|
||
|
||
private var stage: some View {
|
||
ZStack(alignment: .top) {
|
||
VStack(spacing: 0) {
|
||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||
if session.tmuxUnavailable {
|
||
NoTmuxBanner(onInstall: { showTmuxInstall = true },
|
||
onDismiss: { 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(RoundedRectangle(cornerRadius: 6))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6).strokeBorder(p.border, lineWidth: 1)
|
||
)
|
||
// 底部不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||
.padding([.leading, .trailing, .top], TX.Layout.paneGap)
|
||
TerminalHintBar(context: hintContext, hints: hints)
|
||
}
|
||
capsules
|
||
}
|
||
.background(p.bgDeep)
|
||
.contentShape(Rectangle())
|
||
// 点终端任意处收起遮罩态侧边栏(Pin 态不受影响)。
|
||
.simultaneousGesture(TapGesture().onEnded { router.dismissOverlaySidebar() })
|
||
}
|
||
|
||
@ViewBuilder private var paneArea: some View {
|
||
ZStack {
|
||
if let tmux = session.tmuxController {
|
||
TmuxStage(controller: tmux)
|
||
} else {
|
||
RawTerminalView(model: session)
|
||
}
|
||
// 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。
|
||
TerminalStateOverlay(session: session,
|
||
onRetry: { session.retry() },
|
||
onBack: { router.goHome() },
|
||
onCancel: {
|
||
let id = session.id
|
||
router.goHome()
|
||
manager.close(id)
|
||
})
|
||
}
|
||
}
|
||
|
||
private var capsules: 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)
|
||
.padding(.top, 14)
|
||
}
|
||
|
||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||
private func requestClose() {
|
||
router.present(.closeConfirm)
|
||
}
|
||
|
||
/// 执行关闭。tmux 会话默认只 `detach-client`(服务端继续跑);`kill=true` 才 `kill-session`。
|
||
/// 原生窗口没有服务端兜底,直接 teardown。
|
||
private func closeSession(kill: Bool) {
|
||
let id = session.id
|
||
if let tmux = session.tmuxController {
|
||
if kill { tmux.killSession() } else { tmux.detach() }
|
||
}
|
||
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
|
||
// 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
manager.close(id)
|
||
}
|
||
}
|
||
|
||
private func minimizeSession() {
|
||
session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容
|
||
manager.minimize(session.id)
|
||
withAnimation(AppRouter.sidebarAnimation) { 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 hintContext: String {
|
||
switch router.sidebar {
|
||
case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次"
|
||
case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow"
|
||
case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : session.phaseText
|
||
}
|
||
}
|
||
|
||
private var hints: [(key: String, label: String)] {
|
||
switch router.sidebar {
|
||
case .pinned: [("Pin", "解除钉住"), ("⌘\\", "收起为轨道"), ("⌘⇧H", "回首页")]
|
||
case .overlay: [("⌘\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")]
|
||
case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 普通(非 tmux)单终端视图。
|
||
struct RawTerminalView: View {
|
||
@ObservedObject var model: TerminalSession
|
||
@FocusState private var focused: Bool
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: model.state)
|
||
.terminalFocusOnAppear($focused)
|
||
.task {
|
||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
model.markSurfaceReady()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// tmux control-mode 舞台:所有 window 常驻挂载、只显活动 window。
|
||
/// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。
|
||
struct TmuxStage: View {
|
||
@ObservedObject var controller: TmuxController
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.displayScale) private var displayScale
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if controller.windows.isEmpty {
|
||
VStack {
|
||
Spacer()
|
||
Text("正在枚举 tmux window…")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.textWeak)
|
||
Spacer()
|
||
}
|
||
} else {
|
||
// 所有窗口常驻挂载、仅激活窗口可见:切 window 不销毁其它窗口的 pane 视图,
|
||
// 故 ghostty surface 不被释放、grid 内容保留(否则切回旧窗口会空白),
|
||
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
|
||
ZStack {
|
||
ForEach(controller.windows) { win in
|
||
TmuxWindowView(window: win, controller: controller)
|
||
.opacity(win.id == controller.activeWindowID ? 1 : 0)
|
||
.allowsHitTesting(win.id == controller.activeWindowID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
// 容器几何测量放在这一层(windows 为空时同样占满剩余空间、尺寸相同):
|
||
// pane grid 由容器像素决定,须在 attach 前拿到真实容器格子;放 TmuxWindowView 会与
|
||
// "windows 靠 list-windows 才有" 形成鸡生蛋死锁(见 TmuxController.kickoffIfReady)。
|
||
.background(GeometryReader { geo in
|
||
Color.clear
|
||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||
.onChange(of: geo.size) { _, s in
|
||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)+ pane 间拖拽条。
|
||
struct TmuxWindowView: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let controller: TmuxController
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
Group {
|
||
if let layout = window.visibleLayout {
|
||
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))
|
||
ZStack(alignment: .topLeading) {
|
||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||
if let surface = window.panes[item.pane] {
|
||
TmuxPaneView(
|
||
surface: surface,
|
||
isActive: window.activePane == item.pane,
|
||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||
onReady: { controller.markPaneReady(item.pane) }
|
||
)
|
||
.frame(width: CGFloat(item.rect.width) * cw,
|
||
height: CGFloat(item.rect.height) * ch)
|
||
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
|
||
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
|
||
}
|
||
}
|
||
// pane 之间的拖拽条(在 pane 之上,热区 12pt)。
|
||
ForEach(splitters(layout.root), id: \.id) { s in
|
||
PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller)
|
||
}
|
||
}
|
||
} else {
|
||
Color.clear
|
||
}
|
||
}
|
||
// 容器尺寸测量已上移到 TmuxStage(单一来源),此处不再驱动 containerResized。
|
||
}
|
||
}
|
||
|
||
/// 收集布局树叶子(pane + 绝对 rect),identity 只用 paneID → 布局变化不重建 surface view。
|
||
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
|
||
switch node {
|
||
case .leaf(let p, let r): return [(p, r)]
|
||
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
|
||
}
|
||
}
|
||
|
||
/// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**,
|
||
/// 故 resize 目标取"前一个子节点里的任一 pane"即可。
|
||
private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] {
|
||
var out: [SplitterSpec] = []
|
||
func walk(_ n: TmuxLayout.Node) {
|
||
switch n {
|
||
case .leaf:
|
||
break
|
||
case .horizontal(let children, _):
|
||
// 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。
|
||
for (i, child) in children.enumerated() where i < children.count - 1 {
|
||
let r = child.rect
|
||
if let pane = firstPane(child) {
|
||
out.append(SplitterSpec(
|
||
id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane,
|
||
x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width))
|
||
}
|
||
}
|
||
children.forEach(walk)
|
||
case .vertical(let children, _):
|
||
// 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。
|
||
for (i, child) in children.enumerated() where i < children.count - 1 {
|
||
let r = child.rect
|
||
if let pane = firstPane(child) {
|
||
out.append(SplitterSpec(
|
||
id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane,
|
||
x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height))
|
||
}
|
||
}
|
||
children.forEach(walk)
|
||
}
|
||
}
|
||
walk(node)
|
||
return out
|
||
}
|
||
|
||
private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? {
|
||
switch node {
|
||
case .leaf(let p, _): return p
|
||
case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一条分隔位置(格子坐标)。
|
||
struct SplitterSpec {
|
||
let id: String
|
||
/// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高)
|
||
let isVertical: Bool
|
||
/// resize 目标 pane(前一个子节点里的任一 pane)
|
||
let pane: TmuxPaneID
|
||
let x: Int, y: Int
|
||
/// 条的长度(格子)
|
||
let length: Int
|
||
/// 前一个子节点当前的 cols / rows
|
||
let currentSize: Int
|
||
}
|
||
|
||
/// pane 拖拽条:热区 12pt、把手 44×3pt。
|
||
///
|
||
/// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**,
|
||
/// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。
|
||
struct PaneSplitter: View {
|
||
let splitter: SplitterSpec
|
||
let cellW: CGFloat
|
||
let cellH: CGFloat
|
||
let controller: TmuxController
|
||
@State private var drag: CGFloat = 0
|
||
@State private var dragging = false
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var hit: CGFloat { TX.Layout.paneDragHit }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if splitter.isVertical {
|
||
bar(width: hit, height: CGFloat(splitter.length) * cellH)
|
||
.position(x: CGFloat(splitter.x) * cellW + drag,
|
||
y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH)
|
||
} else {
|
||
bar(width: CGFloat(splitter.length) * cellW, height: hit)
|
||
.position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW,
|
||
y: CGFloat(splitter.y) * cellH + drag)
|
||
}
|
||
}
|
||
.gesture(
|
||
DragGesture(minimumDistance: 2)
|
||
.onChanged { v in
|
||
dragging = true
|
||
drag = splitter.isVertical ? v.translation.width : v.translation.height
|
||
}
|
||
.onEnded { _ in
|
||
// 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。
|
||
let cell = splitter.isVertical ? cellW : cellH
|
||
let delta = cell > 0 ? Int((drag / cell).rounded()) : 0
|
||
let newSize = max(1, splitter.currentSize + delta)
|
||
if delta != 0 {
|
||
controller.resizePane(splitter.pane,
|
||
cols: splitter.isVertical ? newSize : nil,
|
||
rows: splitter.isVertical ? nil : newSize)
|
||
}
|
||
drag = 0
|
||
dragging = false
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。
|
||
private func bar(width: CGFloat, height: CGFloat) -> some View {
|
||
ZStack {
|
||
Color.clear.frame(width: width, height: height)
|
||
RoundedRectangle(cornerRadius: 2)
|
||
.fill(dragging ? TXAccent.base : p.borderStrong)
|
||
.frame(width: splitter.isVertical ? 3 : 44,
|
||
height: splitter.isVertical ? 44 : 3)
|
||
.opacity(dragging ? 1 : 0.55)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。
|
||
struct TmuxPaneView: View {
|
||
@ObservedObject var surface: TmuxPaneSurface
|
||
let isActive: Bool
|
||
let onTap: () -> Void
|
||
let onReady: () -> Void
|
||
@FocusState private var focused: Bool
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: surface.state)
|
||
.terminalFocusOnAppear($focused)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.strokeBorder(isActive ? TXAccent.border : p.divider,
|
||
lineWidth: isActive ? 1 : 0.5)
|
||
)
|
||
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
|
||
.overlay {
|
||
if !isActive {
|
||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||
}
|
||
}
|
||
.padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt
|
||
.onChange(of: isActive) { _, active in focused = active }
|
||
.task {
|
||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
onReady()
|
||
}
|
||
}
|
||
}
|