feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法 偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测 格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏 格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证) - 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 + TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容 - 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部 tab,侧边栏改列活动会话;底部动作条合并状态行 - Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需, 缺失时 socket 静默超时) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||||
// 设计稿 `3c 关闭确认`。(`3b 新建分屏菜单` 已随「放弃移动端分屏」移除。)
|
||||
//
|
||||
// 语义分工(务必别混):
|
||||
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||||
@@ -137,148 +137,3 @@ struct CloseConfirmDialog: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user