import SwiftUI // 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。 // // 语义分工(务必别混): // - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。 // - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。 /// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。 struct TXModalScrim: View { let onDismiss: () -> Void @ViewBuilder let content: Content var body: some View { ZStack { Color(hex: 0x0A0B12).opacity(0.42) .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture(perform: onDismiss) content } .transition(.opacity) } } // MARK: - 关闭确认(3c) /// 关闭确认。两套文案由会话能力决定: /// - **tmux 会话**:推荐「仅断开」(`detach-client`,服务端继续跑);左侧次要文字按钮「结束会话」 /// (`kill-session`,**需再确认一次**)。 /// - **原生窗口**:琥珀警告「没有 tmux 兜底,前台进程会随之结束」,推荐「取消」,危险按钮「关闭窗口」。 struct CloseConfirmDialog: View { @ObservedObject var session: TerminalSession /// 仅断开(tmux)/ 直接关闭(原生窗口) let onDetach: () -> Void /// 结束 tmux 会话(kill-session) let onKill: () -> Void let onCancel: () -> Void @EnvironmentObject var theme: TXThemeManager @State private var confirmingKill = false private var p: TXPalette { theme.palette } private var isTmux: Bool { session.tmuxSummary != nil } var body: some View { TXModalScrim(onDismiss: onCancel) { VStack(alignment: .leading, spacing: 0) { Text(title) .font(TX.Font.mono(15, .medium)) .foregroundStyle(p.text) .padding(.bottom, 8) message .padding(.bottom, TX.Space.l) buttons } .padding(TX.Space.l) .frame(width: isTmux ? 436 : 404) .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.dialog) .strokeBorder(p.borderStrong, lineWidth: 1) ) .shadow(color: .black.opacity(0.7), radius: 40, y: 30) } } private var title: String { if let t = session.tmuxSummary, !t.name.isEmpty { return "关闭 \(session.displayName) · \(t.name)?" } // 原生窗口带窗口编号(首版每主机单窗口,恒为 1;多窗口是二期能力)。 return "关闭 \(session.displayName) · 窗口 1?" } @ViewBuilder private var message: some View { if let t = session.tmuxSummary { Text("tmux 会话 (\(t.windows) windows / \(t.panes) panes)。仅断开后服务端继续跑,下次原样接回。") .font(TX.Font.mono(12)) .foregroundStyle(p.text3) .lineSpacing(4) } else { Text("没有 tmux 兜底,") .font(TX.Font.mono(12)) .foregroundStyle(p.text3) + Text("前台进程会随之结束") .font(TX.Font.mono(12, .medium)) .foregroundStyle(p.warning) + Text("。想留着就点黄点最小化。") .font(TX.Font.mono(12)) .foregroundStyle(p.text3) } } @ViewBuilder private var buttons: some View { HStack(spacing: TX.Space.s) { if isTmux { // 次要文字按钮(危险):kill-session 需再点一次确认。 Button { if confirmingKill { onKill() } else { confirmingKill = true } } label: { Text(confirmingKill ? "确认结束会话?" : "结束会话") .font(TX.Font.mono(12, confirmingKill ? .semibold : .regular)) .foregroundStyle(p.danger) } .buttonStyle(.plain) } Spacer() TXButton(title: "取消", action: onCancel) if isTmux { TXButton(title: "仅断开 ⏎", emphasized: true, action: onDetach) } else { dangerButton } } } /// 危险按钮(设计给的精确色:描边 #8d5560 / 底 rgba(92,58,63,.35) / 字 #f5a0ac)。 private var dangerButton: some View { Button(action: onDetach) { Text("关闭窗口") .font(TX.Font.mono(12, .medium)) .foregroundStyle(Color(hex: 0xF5A0AC)) .padding(.horizontal, 14) .frame(height: 32) .background(Color(hex: 0x5C3A3F).opacity(0.35), in: RoundedRectangle(cornerRadius: TX.Radius.control)) .overlay( RoundedRectangle(cornerRadius: TX.Radius.control) .strokeBorder(Color(hex: 0x8D5560), lineWidth: 1) ) } .buttonStyle(.plain) } } // 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) ) .shadow(color: .black.opacity(0.7), radius: 40, y: 30) } } 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(.plain) .disabled(disabled) } }