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:
@@ -5,13 +5,13 @@ import SwiftUI
|
||||
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
|
||||
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
|
||||
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 /
|
||||
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / window 徽标 /
|
||||
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
|
||||
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
|
||||
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
|
||||
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
|
||||
//
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在
|
||||
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/window 行)在
|
||||
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
|
||||
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
|
||||
// `allowsHitTesting(expanded)`。
|
||||
@@ -32,8 +32,6 @@ private struct LeadingHitShape: Shape {
|
||||
struct TerminalSidebar: View {
|
||||
@ObservedObject var manager: SessionManager
|
||||
@ObservedObject var router: AppRouter
|
||||
/// 当前会话(tmux 段列它的 window)。
|
||||
@ObservedObject var session: TerminalSession
|
||||
@ObservedObject var tailscaleStore: TailscaleStore
|
||||
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||||
let railWidth: CGFloat
|
||||
@@ -56,6 +54,8 @@ struct TerminalSidebar: View {
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// 活动会话(主机连接)的切换在侧边栏;tmux window 切换已上移为终端顶部 Tab 条
|
||||
// (TmuxWindowTabGroup,用户定稿),侧边栏不再重复列 window。
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
@@ -65,9 +65,6 @@ struct TerminalSidebar: View {
|
||||
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
|
||||
sessionRows
|
||||
newHostRow
|
||||
tmuxDivider
|
||||
tmuxLabelRow
|
||||
tmuxRows
|
||||
Spacer(minLength: TX.Space.s)
|
||||
tailscaleRow
|
||||
footRow
|
||||
@@ -231,101 +228,6 @@ struct TerminalSidebar: View {
|
||||
.accessibilityLabel("连接新主机")
|
||||
}
|
||||
|
||||
// MARK: - tmux 段
|
||||
|
||||
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
|
||||
private var tmuxDivider: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
shortDivider
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
|
||||
startPoint: .leading, endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.padding(.vertical, TX.Space.s)
|
||||
}
|
||||
|
||||
/// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写);
|
||||
/// 展开 = 完整分组标题(tmux · 主机 · 会话名)。
|
||||
private var tmuxLabelRow: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
|
||||
.font(TX.Font.mono(9, .semibold))
|
||||
.tracking(0.9)
|
||||
.foregroundStyle(TXAccent.mid)
|
||||
.frame(width: railWidth)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
TXSectionLabel(text: tmuxHeader)
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 22)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder private var tmuxRows: some View {
|
||||
if let tmux = session.tmuxController {
|
||||
ForEach(tmux.windows) { win in
|
||||
SidebarWindowRow(window: win,
|
||||
isSelected: win.id == tmux.activeWindowID,
|
||||
expanded: expanded, railWidth: railWidth,
|
||||
hitWidth: currentWidth) {
|
||||
tmux.selectWindow(win.id)
|
||||
}
|
||||
}
|
||||
newWindowRow { tmux.newWindow() }
|
||||
} else if let summary = session.tmuxSummary {
|
||||
// 已知是 tmux 会话但 controller 还没 attach:按概览占位,保持两态节奏稳定。
|
||||
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
|
||||
WindowRowBody(
|
||||
index: "\(i)",
|
||||
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
|
||||
subtitle: i == 0 && summary.panes > 0
|
||||
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
|
||||
isSelected: i == 0,
|
||||
expanded: expanded, railWidth: railWidth)
|
||||
}
|
||||
newWindowRow {}
|
||||
} else {
|
||||
Text("无 tmux · 客户端窗口")
|
||||
.font(TX.Font.mono(11.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.padding(.leading, railWidth)
|
||||
.frame(height: 32, alignment: .leading)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
|
||||
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 0) {
|
||||
Color.clear.frame(width: railWidth, height: 1)
|
||||
Text("新建 window")
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text4)
|
||||
Spacer(minLength: 0)
|
||||
Text("⌘T")
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
.padding(.trailing, TX.Space.m)
|
||||
}
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
.allowsHitTesting(expanded)
|
||||
}
|
||||
|
||||
private var tmuxHeader: String {
|
||||
let host = session.displayName
|
||||
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
|
||||
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
|
||||
}
|
||||
|
||||
// MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮)
|
||||
|
||||
/// tailnet 状态:点在图标列(两态可见),文字是明细。
|
||||
@@ -391,6 +293,7 @@ enum RailSidebarStyle {
|
||||
}
|
||||
|
||||
// MARK: - 行
|
||||
// (window 行已随「window 切换上移为顶部 Tab」移除,见 TerminalChrome.TmuxWindowTabGroup。)
|
||||
|
||||
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
|
||||
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
|
||||
@@ -471,88 +374,3 @@ struct SidebarSessionRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// window 行:图标列 26×22 编号徽标(两态同位,选中态徽标自己高亮)+ 明细(title + cwd/panes)。
|
||||
struct SidebarWindowRow: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isSelected: Bool
|
||||
let expanded: Bool
|
||||
let railWidth: CGFloat
|
||||
let hitWidth: CGFloat
|
||||
let onTap: () -> Void
|
||||
|
||||
private var subtitle: String {
|
||||
var parts: [String] = []
|
||||
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
|
||||
let n = window.panes.count
|
||||
if n > 1 { parts.append("\(n) panes") }
|
||||
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
|
||||
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
|
||||
.contentShape(LeadingHitShape(width: hitWidth))
|
||||
}
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
/// window 行的视觉本体(SidebarWindowRow 与占位行共用)。
|
||||
struct WindowRowBody: View {
|
||||
let index: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let isSelected: Bool
|
||||
let expanded: Bool
|
||||
let railWidth: CGFloat
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(index)
|
||||
.font(TX.Font.mono(11, .semibold))
|
||||
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
|
||||
.frame(width: 26, height: 22)
|
||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||
)
|
||||
.frame(width: railWidth)
|
||||
HStack(spacing: TX.Space.s) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
||||
.foregroundStyle(isSelected ? p.text : p.text2)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textWeak)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if isSelected {
|
||||
TXStatusDot(color: p.online, size: 7)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, TX.Space.m)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
}
|
||||
.frame(height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.fill(TXAccent.selectedBg.opacity(0.75))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, TX.Space.xs)
|
||||
.opacity(isSelected && expanded ? 1 : 0)
|
||||
)
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user