import SwiftUI import GhosttyTerminal import TXCore struct ContentView: View { @StateObject private var model = SSHTerminalModel() @Environment(\.scenePhase) private var scenePhase var body: some View { // M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。 // 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。 if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty, (UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty { TsnetProbeView(authKey: tsnetKey) } else { Group { if model.showsTerminal { TerminalScreen(model: model) } else { ConnectionForm(model: model) } } .onAppear { model.autoConnectIfConfigured() } .onChange(of: scenePhase) { _, phase in switch phase { case .background: model.enterBackground() case .active: model.enterForeground() default: break } } } } } /// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。 struct ConnectionForm: View { @ObservedObject var model: SSHTerminalModel @State private var host = "" @State private var port = "22" @State private var username = "" @State private var password = "" var body: some View { NavigationStack { Form { Section("SSH 直连 (M0)") { TextField("主机 / IP", text: $host) .autocorrectionDisabled() .textInputAutocapitalization(.never) TextField("端口", text: $port) .keyboardType(.numberPad) TextField("用户名", text: $username) .autocorrectionDisabled() .textInputAutocapitalization(.never) SecureField("密码", text: $password) } Section { Button { model.connect(host: host, port: Int(port) ?? 22, username: username, password: password) } label: { Text("连接").frame(maxWidth: .infinity) } .disabled(host.isEmpty || username.isEmpty) } Section("状态") { Text(model.phaseText) .foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary) if !model.banner.isEmpty { Text(model.banner).font(.caption).foregroundStyle(.secondary) } } } .navigationTitle("terminalX") } } } struct TerminalScreen: View { @ObservedObject var model: SSHTerminalModel var body: some View { if let tmux = model.tmuxController { TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab } else { RawTerminalView(model: model) // 普通单终端 } } } /// 普通(非 tmux)单终端视图。 struct RawTerminalView: View { @ObservedObject var model: SSHTerminalModel @FocusState private var focused: Bool var body: some View { TerminalSurfaceView(context: model.state) .terminalFocusOnAppear($focused) .overlay(alignment: .top) { if model.phaseText != "已连接", !model.banner.isEmpty { Text(model.banner) .font(.caption) .padding(.horizontal, 10).padding(.vertical, 6) .background(.thinMaterial, in: Capsule()) .padding(.top, 6) } } .task { for _ in 0 ..< 300 where model.state.surfaceSize == nil { try? await Task.sleep(nanoseconds: 30_000_000) } model.markSurfaceReady() } } } /// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。 struct TmuxTabbedView: View { @ObservedObject var controller: TmuxController var body: some View { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(controller.windows) { win in TmuxTabChip(window: win, isActive: win.id == controller.activeWindowID) { controller.selectWindow(win.id) } } Button { controller.newWindow() } label: { Image(systemName: "plus").padding(.horizontal, 6) } } .padding(.horizontal, 8).padding(.vertical, 6) } .background(.thinMaterial) Divider() if let win = controller.activeWindow { TmuxWindowView(window: win, controller: controller) .id(win.id.raw) } else { Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer() } } } } struct TmuxTabChip: View { @ObservedObject var window: TmuxWindow let isActive: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { Text(window.title) .font(.caption).lineLimit(1) .padding(.horizontal, 12).padding(.vertical, 6) .background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } } /// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。 struct TmuxWindowView: View { @ObservedObject var window: TmuxWindow let controller: TmuxController @Environment(\.displayScale) private var displayScale 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) } } } } else { Color.clear } } // iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。 .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) } } } /// 收集布局树叶子(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) } } } /// 单个 pane 的终端面 + 焦点边框 + 点击选中。 struct TmuxPaneView: View { @ObservedObject var surface: TmuxPaneSurface let isActive: Bool let onTap: () -> Void let onReady: () -> Void @FocusState private var focused: Bool var body: some View { TerminalSurfaceView(context: surface.state) .terminalFocusOnAppear($focused) .overlay( Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35), lineWidth: isActive ? 2 : 0.5) ) // 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。 .overlay { if !isActive { Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() } } } .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() } } }