侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份, 内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画), 图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开 - 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«; 删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸 - 修 GridBox 从不通知 UI:onGridChange→objectWillChange, 标题胶囊格子数随开合实时刷新 触控化走查(上一轮,真机部署验证): - 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮 - TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射) - 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
12 KiB
Swift
285 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||
//
|
||
// 语义分工(务必别混):
|
||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
||
|
||
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
||
///
|
||
/// 遮罩淡入、卡片从 96% 长出来(`TX.Motion.pop`)—— 两段分开写才有层次;整块套一个
|
||
/// `.transition(.opacity)` 的话卡片只是跟着变淡,弹出感全无。
|
||
/// 动画事务由 `AppRouter.present/dismissModal` 提供。
|
||
struct TXModalScrim<Content: View>: View {
|
||
let onDismiss: () -> Void
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
TX.Scrim.color.opacity(TX.Scrim.dialog)
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: onDismiss)
|
||
.transition(.opacity)
|
||
content
|
||
.transition(TX.Motion.pop)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
)
|
||
.txShadow(TX.Elevation.dialog)
|
||
}
|
||
}
|
||
|
||
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(.txPress)
|
||
}
|
||
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(.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)
|
||
}
|
||
}
|