import SwiftUI import TXCore /// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。 /// /// **项目风格的居中弹框**(用户裁决第三轮:曾是系统 sheet——顶部手柄 + 半屏形态读作"页面"; /// 分段控件 Herdr/最近 是占位噪声,连同 Header 一起删)。与 `CloseConfirmDialog` 同一套 /// 卡片语言:TXModalScrim + surfaceDark 卡 + borderStrong 描边 + dialog 阴影。 /// /// 三种连接方式并列:**接回已有 tmux 会话**(列表行)· **新建 tmux 会话** · **原生终端**。 /// 服务器**没装 tmux 时不显示本弹框** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。 /// 点遮罩 = 取消本次连接(弹框语义:点外面就是不想连了)。 struct SessionPickerDialog: View { /// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。 let host: String let choice: TmuxSessionChoice let onAttach: (String) -> Void let onCreate: (String) -> Void let onNative: () -> Void /// 点遮罩:取消本次连接(关闭正在建立的会话)。 let onCancel: () -> Void @EnvironmentObject var theme: TXThemeManager @State private var newName = "" @State private var creating = false @FocusState private var nameFocused: Bool private var p: TXPalette { theme.palette } var body: some View { TXModalScrim(onDismiss: onCancel) { VStack(alignment: .leading, spacing: TX.Space.m) { title if choice.sessions.isEmpty { emptyState } else { sessionList } newSessionRow nativeRow } .padding(TX.Space.l) .frame(width: 480) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.dialog) .strokeBorder(p.borderStrong, lineWidth: 1) ) .txShadow(TX.Elevation.dialog) } } private var title: some View { VStack(alignment: .leading, spacing: 3) { Text("连接到 \(host)") .font(TX.Font.mono(15, .medium)) .foregroundStyle(p.text) Text("接回一个 tmux 会话,或直接用原生终端") .font(TX.Font.mono(12)) .foregroundStyle(p.textWeak) } } // MARK: - 已有会话列表 private var sessionList: some View { // 会话多时限高滚动(弹框不该长过屏幕);少时按内容高,不留空槽。 Group { if choice.sessions.count > 4 { ScrollView { sessionRows } .frame(height: 264) } else { sessionRows } } .background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card)) } private var sessionRows: some View { VStack(spacing: 0) { ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in Button { onAttach(s.name) } label: { sessionRow(s) } .buttonStyle(.txPress) if index < choice.sessions.count - 1 { Rectangle().fill(p.divider).frame(height: 1) .padding(.horizontal, TX.Space.m) } } } } private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: TX.Space.s) { Text(s.name) .font(TX.Font.mono(14, .medium)) .foregroundStyle(p.text) // tmux 会话本身就是活着的服务端进程 → 一律「活跃」;被别的客户端占用时另标。 TXBadge(text: "活跃", emphasized: true) if s.isAttached { TXBadge(text: "已被接入") } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(p.textFaint) } Text(subtitle(s)) .font(TX.Font.mono(11)) .foregroundStyle(p.textWeak) } .padding(.horizontal, TX.Space.m) .padding(.vertical, TX.Space.s + 2) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } private func subtitle(_ s: TmuxSessionProbe.Session) -> String { var parts = ["\(s.windows) 个窗口"] if let t = s.lastActivity { parts.append(SessionCard.relative(t)) } return parts.joined(separator: " · ") } private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Text("这台主机上还没有 tmux 会话") .font(TX.Font.mono(13, .medium)) .foregroundStyle(p.text) Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。") .font(TX.Font.mono(11.5)) .foregroundStyle(p.textWeak) .lineSpacing(3) } .padding(TX.Space.m) .frame(maxWidth: .infinity, alignment: .leading) .background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card)) } // MARK: - 新建 tmux 会话 @ViewBuilder private var newSessionRow: some View { if creating { HStack(spacing: TX.Space.s) { TXSearchField(text: $newName, placeholder: choice.defaultNewName) .focused($nameFocused) TXButton(title: "创建", emphasized: true) { let name = newName.trimmingCharacters(in: .whitespaces) onCreate(name.isEmpty ? choice.defaultNewName : name) } TXButton(title: "取消") { creating = false } } } else { Button { creating = true nameFocused = true } label: { HStack(spacing: TX.Space.s) { Image(systemName: "plus") .font(.system(size: 12, weight: .medium)) Text("新建 tmux 会话…") .font(TX.Font.mono(13, .medium)) Spacer(minLength: 0) } .foregroundStyle(TXAccent.textBright) .padding(.horizontal, TX.Space.m) .frame(height: 44) .background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .contentShape(Rectangle()) } .buttonStyle(.txPress) } } // MARK: - 原生终端(并列的第三种连接方式,不是"跳过") private var nativeRow: some View { Button(action: onNative) { HStack(spacing: TX.Space.s) { Image(systemName: "terminal") .font(.system(size: 12, weight: .medium)) .foregroundStyle(p.text3) Text("原生终端") .font(TX.Font.mono(13, .medium)) .foregroundStyle(p.text) Text("不经过 tmux") .font(TX.Font.mono(11)) .foregroundStyle(p.textWeak) Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(p.textFaint) } .padding(.horizontal, TX.Space.m) .frame(height: 44) .background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card)) .contentShape(Rectangle()) } .buttonStyle(.txPress) .accessibilityLabel("直接连接原生终端,不经过 tmux") } }