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:
kid
2026-07-26 11:11:20 +08:00
parent 5707d70dff
commit 5d1abdc7ac
13 changed files with 411 additions and 693 deletions

View File

@@ -196,7 +196,7 @@ struct TerminalScreen: View {
// **** TerminalSidebar
// 56 288
// resize
TerminalSidebar(manager: manager, router: router, session: session,
TerminalSidebar(manager: manager, router: router,
tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
@@ -204,7 +204,7 @@ struct TerminalScreen: View {
stage(safeTop: safeTop, safeBottom: safeBottom)
}
.background(p.bgDeep)
// 3c3b alert/sheet
// 3c alert/sheet
.overlay {
switch router.modal {
case .closeConfirm:
@@ -213,13 +213,6 @@ struct TerminalScreen: View {
onDetach: { router.dismissModal(); closeSession(kill: false) },
onKill: { router.dismissModal(); closeSession(kill: true) },
onCancel: { router.dismissModal() })
case .splitMenu(let vertical):
SplitMenuView(session: session, vertical: vertical,
onPick: { target in
router.dismissModal()
performSplit(target, vertical: vertical)
},
onCancel: { router.dismissModal() })
case nil:
EmptyView()
}
@@ -258,47 +251,48 @@ struct TerminalScreen: View {
// MARK: -
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
// tmux 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.padding(.top, TX.Layout.capsuleInset)
.transition(.opacity)
}
// pane **** +
// surface 86pt
paneArea
// 2c 122pt
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(paneShape)
.overlay(
paneShape.strokeBorder(p.border, lineWidth: 1)
// /
// 线
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
)
// **** 2pt /
// pane 线
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
VStack(spacing: 0) {
// 稿/ + + tmux window tab
// **** chrome pane
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
.padding(.horizontal, TX.Space.m)
// iPad 14/
.padding(.top, TX.Layout.capsuleTop(safeTop))
.padding(.bottom, TX.Space.s)
// tmux 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.transition(.opacity)
}
capsules(safeTop: safeTop)
paneArea
// pane
.padding(.top, TX.Space.s)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(paneShape)
.overlay(
paneShape.strokeBorder(p.border, lineWidth: 1)
//
// 线 chrome
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: -1))
)
// **** 2pt
// pane 线
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom)
}
.background(p.bgDeep)
}
/// pane ** app chrome **
///
/// iPad/iPhone 6pt
/// iPad/iPhone 6pt
/// ""
/// /****
/// pane chrome Stage Manager
/// ****
/// pane chrome
private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane,
@@ -328,17 +322,6 @@ struct TerminalScreen: View {
}
}
private func capsules(safeTop: CGFloat) -> some View {
HStack(alignment: .top) {
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
Spacer(minLength: TX.Space.m)
TerminalStatusCapsule(session: session)
}
.padding(.horizontal, TX.Space.m)
// iPad 14/
.padding(.top, TX.Layout.capsuleTop(safeTop))
}
/// **** `closeSession(kill:)`
private func requestClose() {
router.present(.closeConfirm)
@@ -365,47 +348,11 @@ struct TerminalScreen: View {
router.goHome()
}
///
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
guard let tmux = session.tmuxController else { return }
switch target {
case .currentSession:
tmux.splitWindow(vertical: vertical)
case .otherSession(let name):
tmux.splitWindow(vertical: vertical, joinSession: name)
case .newSession:
// tmux
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
case .nativeWindow:
break // HANDOFF §12.4
}
}
/// D / D list-sessions detached
private func requestSplit(vertical: Bool) {
session.tmuxController?.listSessions()
router.present(.splitMenu(vertical: vertical))
}
/// **** ·
///
private var barContext: String {
if !session.isLive { return session.phaseText }
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
return session.displayName
}
/// ****tmux
/// **** window
/// `TerminalActionBar.statusLine`
private var barActions: [TerminalActionBar.Action] {
let tmux = session.tmuxController
return [
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
requestSplit(vertical: true)
},
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
enabled: tmux != nil) {
tmux?.newWindow()
@@ -489,12 +436,16 @@ struct TmuxWindowView: View {
let root = layout.root.rect
let cw = geo.size.width / CGFloat(max(1, root.width))
let ch = geo.size.height / CGFloat(max(1, root.height))
let items = leaves(layout.root)
ZStack(alignment: .topLeading) {
ForEach(leaves(layout.root), id: \.pane.raw) { item in
ForEach(items, id: \.pane.raw) { item in
if let surface = window.panes[item.pane] {
TmuxPaneView(
surface: surface,
isActive: window.activePane == item.pane,
// panesurface
// paneViewportChanged
solo: items.count == 1,
onTap: { controller.selectPane(item.pane, in: window.id) },
onReady: { controller.markPaneReady(item.pane) }
)
@@ -651,6 +602,8 @@ struct PaneSplitter: View {
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
/// window pane pane surface ==
let solo: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool
@@ -672,7 +625,7 @@ struct TmuxPaneView: View {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.padding(TX.Layout.paneGap / 2) // pane 1pt 2pt
.padding(solo ? 0 : TX.Layout.paneGap / 2) // pane 1pt 2pt pane
.onChange(of: isActive) { _, active in focused = active }
.task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {