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

@@ -12,6 +12,11 @@
<string>JetBrainsMono-SemiBold.ttf</string>
<string>JetBrainsMono-Bold.ttf</string>
</array>
<!-- 本地网络权限iOS 14+SSH/mosh 直连 192.168.x.x 等 LAN 主机必须。
没有这个键,新装/重装后系统可能不弹授权框直接静默拒绝 —— 表现为 socket 纯超时
2026-07-26 真机踩坑devicectl 重装后连 LAN 主机 12s 超时,别的 app 正常)。 -->
<key>NSLocalNetworkUsageDescription</key>
<string>terminalX 需要访问本地网络,才能通过 SSH/mosh 连接局域网内的服务器与设备。</string>
<key>CFBundleURLTypes</key>
<array>
<dict>

View File

@@ -32,13 +32,11 @@ enum SidebarState: Equatable {
}
}
/// router `@State`W/D URL
/// router `@State`W URL
///
enum AppModal: Equatable {
/// 3c
case closeConfirm
/// 3b`vertical` = D/ D
case splitMenu(vertical: Bool)
}
///
@@ -46,13 +44,13 @@ enum AppModal: Equatable {
final class AppRouter: ObservableObject {
@Published var route: AppRoute = .home
@Published var sidebar: SidebarState = .collapsed
/// /
///
@Published var modal: AppModal?
/// `TX.Motion.standard`稿 200260ms easeOut
///
/// ** / / 线** `transition`
/// 退 ""
/// 退 ""
/// router URL
private func animated(_ change: () -> Void) {
withAnimation(TX.Motion.standard, change)
@@ -76,7 +74,7 @@ final class AppRouter: ObservableObject {
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
}
/// /WDD URL
/// /W URL
func present(_ modal: AppModal) { animated { self.modal = modal } }
func dismissModal() { animated { modal = nil } }
}

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 {

View File

@@ -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)
}
}

View File

@@ -166,8 +166,7 @@ final class SessionManager: ObservableObject {
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
let s = makeSession()
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
mosh: host.useMosh, tmux: spec.tmux,
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
mosh: host.useMosh, tmux: spec.tmux)
//
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
sessions.append(s)

View File

@@ -1,39 +1,20 @@
import SwiftUI
// 稿 `1b -` chrome + +
// `1b` ** chrome **tab
// **** bgDeep pane 86pt
//
// absolute + blur
// 86pt padding clipped
//
// [ ] · · [tab ]
// tab = tmux window window tab
// TerminalActionBar.statusLine
/// blur + + + 11
/// + + tmux window tab
///
/// ****`TX.Elevation.floating`
/// `0 10px 30px rgba(0,0,0,.55)`
/// "" .92
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
private var p: TXPalette { theme.palette }
var body: some View {
content
.padding(.horizontal, TX.Space.s)
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.92))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.txShadow(TX.Elevation.floating)
}
}
/// / window title · cwd
struct TerminalTitleCapsule: View {
/// 稿 / ****
/// ****
/// tab
/// window
struct TerminalTopBar: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@@ -41,51 +22,55 @@ struct TerminalTitleCapsule: View {
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.m) {
// 沿 macOS
HStack(spacing: TX.Space.xs) {
// 沿 macOS
// windowButton
windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose)
// chevron 9.5pt
//
// chevron =
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.leading, 4)
.padding(.trailing, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 resize
// GridBox.onGridChange session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
}
titleLine
TmuxWindowTabGroup(session: session)
Spacer(minLength: 0)
}
.frame(height: 40)
}
/// + (text2) + window title · cwd(textWeak) + (textFaint)
/// text2
private var titleLine: some View {
HStack(spacing: TX.Space.xs) {
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5)
Text(session.displayName)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.text2)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// resize GridBox.onGridChange
// session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
}
/// window title · cwd稿 3a tmux 退
/// cwd window title tab chip
/// tmux tab 退
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
return t.cwd.isEmpty ? "" : Self.abbreviate(t.cwd)
}
/// `/Users/yz/src/x` `~/src/x`稿
/// `/Users/yz/src/x` `~/src/x`
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
@@ -99,31 +84,29 @@ struct TerminalTitleCapsule: View {
}
private var gridLabel: String {
// tmux controller raw screenGrid RawTerminalView
// raw surface screenGrid
if let g = session.tmuxController?.clientGrid, g.cols > 0 {
return "\(g.cols)×\(g.rows)"
}
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
/// /
///
/// macOS 13pt ****
/// mac hover hover 13pt
/// "" mac app
///
///
/// 22pt + ****`` / `` =
/// == 14% / 26×34
/// / 28pt + **** hover
/// == 14% app
private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 9.5, weight: .bold))
.font(.system(size: 10.5, weight: .bold))
.foregroundStyle(tint)
.frame(width: 22, height: 22)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
.frame(width: 28, height: 28)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
RoundedRectangle(cornerRadius: 8).strokeBorder(tint.opacity(0.45), lineWidth: 1)
)
.frame(width: 26, height: 34)
.frame(width: 30, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
@@ -131,84 +114,123 @@ struct TerminalTitleCapsule: View {
}
}
/// `tsnet | mosh | tmux` 1×16pt 线
/// tmux window **tab ** surfaceDark window chip
///
/// **** tmux
/// tmux · RTT HANDOFF §12.4
struct TerminalStatusCapsule: View {
/// tab tmux chip
/// accent + chip `+` window tab
/// controller attach tmux / fixture
struct TmuxWindowTabGroup: View {
@ObservedObject var session: TerminalSession
var body: some View {
if let tmux = session.tmuxController {
WindowTabGroupRow(controller: tmux)
} else if let summary = session.tmuxSummary {
TabGroupSlot {
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
WindowTabChipBody(
index: "\(i)",
title: i == 0 && !summary.currentTitle.isEmpty
? summary.currentTitle : "window \(i)",
isSelected: i == 0)
}
}
}
}
}
/// tmux tab controller ObservableObject
private struct WindowTabGroupRow: View {
@ObservedObject var controller: TmuxController
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(tmuxText, color: p.text2, dot: false)
TabGroupSlot {
ForEach(controller.windows) { win in
WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) {
controller.selectWindow(win.id)
}
}
// `+` window
Button { controller.newWindow() } label: {
Image(systemName: "plus")
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(p.text4)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
.accessibilityLabel("新建 window")
}
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 16)
.padding(.horizontal, TX.Space.s)
/// tab 3ptchip
private struct TabGroupSlot<Content: View>: View {
@ViewBuilder let content: Content
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
// +**** ScrollView ScrollView
// chip
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) { content }
.padding(3)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
}
.frame(height: 36)
}
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
/// window chiptitle/index window @Published
private struct WindowTabChip: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
WindowTabChipBody(index: "\(window.index)", title: window.title, isSelected: isSelected)
}
.buttonStyle(.txPressTight)
.accessibilityLabel("切换到 window \(window.index)")
}
}
/// chip window ` `
private struct WindowTabChipBody: View {
let index: String
let title: String
let isSelected: Bool
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(color)
Text(index)
.font(TX.Font.mono(10.5, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.textFaint)
Text(title)
.font(TX.Font.mono(12, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text3)
.lineLimit(1)
}
}
// tsnet /
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// mosh / SSH
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// RTT 绿/ RTT
return p.text2
}
//
private var tmuxText: String {
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
// / tmux
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : ""
.padding(.horizontal, TX.Space.s)
.frame(height: 28)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.capsule - 3))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule - 3)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.contentShape(Rectangle())
.animation(TX.Motion.quick, value: isSelected)
}
}
@@ -402,17 +424,22 @@ struct TmuxInstallSheet: View {
}
}
///
/// + ****
///
/// ****iPad / window /
/// `D`
/// **** +
/// `[dot ] | | `
/// **** /
/// tmux tmux ·
/// tab
///
/// ****iPad window /
/// `T`
/// UI-5 线
///
/// = 34pt + home indicator **** 齿
/// 线 34pt 24pt ""
struct TerminalActionBar: View {
/// /
let context: String
@ObservedObject var session: TerminalSession
let actions: [Action]
let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager
@@ -423,20 +450,17 @@ struct TerminalActionBar: View {
let id: String
let icon: String
let title: String
/// tmux
/// window
var enabled: Bool = true
let run: () -> Void
}
/// iPhone 34pt
/// iPhone 34pt
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
statusLine
Spacer(minLength: TX.Space.s)
ForEach(shown) { action in
Button(action: action.run) {
@@ -465,6 +489,80 @@ struct TerminalActionBar: View {
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footBand(safeBottom))
.animation(TX.Motion.standard, value: context)
.animation(TX.Motion.standard, value: contextText)
}
// MARK: -
private var statusLine: some View {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(contextText, color: p.text3, dot: false)
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 12)
.padding(.horizontal, TX.Space.xs)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// tsnet /
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// mosh / SSH
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// RTT 绿/ RTT
return p.text3
}
// windows
private var contextText: String {
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
// / tmux
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : session.phaseText
}
}

View File

@@ -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 = Dfalse = 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)
}
}

View File

@@ -225,14 +225,6 @@ final class TerminalSession: ObservableObject, Identifiable {
/// tmux `tmuxSummary`
private var injectedTmuxSummary: TmuxSummary?
/// tmux detached controller.sessions
private(set) var injectedTmuxSessions: [TmuxSessionInfo] = []
/// controller list-sessions
var tmuxSessionList: [TmuxSessionInfo] {
if let c = tmuxController, !c.sessions.isEmpty { return c.sessions }
return injectedTmuxSessions
}
/// tmux controller
var tmuxSummary: TmuxSummary? {
@@ -579,10 +571,9 @@ final class TerminalSession: ObservableObject, Identifiable {
/// // UIPreviewFixture
/// `gate` `session.receive` surface attach flush
func injectFixture(host: SavedHost, lines: [String], link: LinkState, mosh: Bool,
tmux: TmuxSummary? = nil, tmuxSessions: [TmuxSessionInfo] = []) {
tmux: TmuxSummary? = nil) {
isSyntheticFixture = true
injectedTmuxSummary = tmux
injectedTmuxSessions = tmuxSessions
self.host = host
connectionTitle = host.name
isLive = true

View File

@@ -293,8 +293,6 @@ enum TX {
static let paneDragHit: CGFloat = 12
///
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// **** / / 齿线
/// 线 y home indicator
/// chrome `TX.Layout.footBand`

View File

@@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
init(id: TmuxPaneID, cols: Int, rows: Int,
onInput: @escaping @Sendable (Data) -> Void,
onMetrics: @escaping @Sendable (Int, Int) -> Void) {
onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
self.id = id
self.grid = (cols, rows)
let session = InMemoryTerminalSession(
write: { data in onInput(data) }, // pane send-keys -t %self
// tmux surface resize cell tmux
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
// tmux surface resize ** + cell **cols/rows/cellW/cellH
// controller pane window paneViewportChanged
// pane cell
resize: { vp in onViewport(Int(vp.columns), Int(vp.rows),
Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
@@ -54,16 +57,6 @@ final class TmuxWindow: ObservableObject, Identifiable {
}
}
/// tmux `list-sessions`
struct TmuxSessionInfo: Identifiable, Equatable {
var id: String { name }
let name: String
let windows: Int
/// attach attached
let isAttached: Bool
let lastActivity: Date?
}
/// tmux control-mode `%`- window tabwindow pane
/// "线 b"app `refresh-client -C WxH` tmux pane surface
/// layout rect%output tmux pane /
@@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable {
final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID?
/// tmux server `listSessions()`
@Published private(set) var sessions: [TmuxSessionInfo] = []
private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
@@ -82,6 +73,8 @@ final class TmuxController: ObservableObject {
var onExit: (() -> Void)?
private var clientCols = 80, clientRows = 24
/// tmux raw screenGrid RawTerminalView
@Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0)
private var cellWpx = 0, cellHpx = 0 // cell
private var lastSentCols = 0, lastSentRows = 0
private var resizeDebounce: Task<Void, Never>?
@@ -89,12 +82,11 @@ final class TmuxController: ObservableObject {
// control-mode FIFO
private var attachAcked = false
private enum PendingKind: CustomStringConvertible {
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
var description: String {
switch self {
case .ignore: "ignore"
case .listWindows: "listWindows"
case .listSessions: "listSessions"
case .currentSession: "currentSession"
case .capturePane(let p): "capturePane(%\(p.raw))"
}
@@ -122,8 +114,9 @@ final class TmuxController: ObservableObject {
private func kickoffIfReady() {
guard attachAcked, containerKnown, !kickoffDone else { return }
kickoffDone = true
lastSentCols = clientCols; lastSentRows = clientRows //
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") //
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface
pending.append(.ignore)
let sep = Self.fieldSep
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
@@ -132,30 +125,64 @@ final class TmuxController: ObservableObject {
// attach `demo`
sendCommand("display-message -p \"#{session_name}\"")
pending.append(.currentSession)
listSessions() //
}
/// model attach tmux refresh-client -C
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
func setClientSize(cols: Int, rows: Int) {
clientCols = max(1, cols); clientRows = max(1, rows)
clientGrid = (clientCols, clientRows)
}
/// cell raw pane surface metrics
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
/// pane surface metrics cell tmux
func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) }
/// pane surface ****
///
/// containerResized ghostty /
/// px ÷ cell px surface ** 1 /** 164×51 163×50
/// surface
/// TUIClaude Code
/// pane window pane ghostty
/// refresh-client tmux
func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) {
setCellPixels(w: cellW, h: cellH)
guard kickoffDone, cols > 0, rows > 0 else { return }
// pane window pane cell
guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return }
guard cols != lastSentCols || rows != lastSentRows else { return }
lastSentCols = cols; lastSentRows = rows
clientCols = cols; clientRows = rows
clientGrid = (cols, rows)
resizeDebounce?.cancel()
resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 120_000_000) //
guard !Task.isCancelled, let self else { return }
NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)")
self.sendCommand("refresh-client -C \(cols)x\(rows)")
}
}
/// iPadOS // + debounce refresh-client -C
/// " × displayScale ÷ cell " surface resize
/// " × displayScale ÷ cell "**** pane window
/// paneViewportChanged 1 /
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
containerKnown = true
if !kickoffDone {
// kickoff setClientSize raw surface
// attach RawTerminalView TmuxStage ghostty 沿
NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)")
kickoffIfReady()
return
}
if windows.contains(where: { $0.panes.count == 1 }) { return } //
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
guard cols != lastSentCols || rows != lastSentRows else { return }
lastSentCols = cols; lastSentRows = rows
clientCols = cols; clientRows = rows
clientGrid = (cols, rows)
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
// attach kickoff debounce attach client ==
containerKnown = true
if !kickoffDone { kickoffIfReady(); return }
resizeDebounce?.cancel()
resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 200_000_000) //
@@ -256,7 +283,9 @@ final class TmuxController: ObservableObject {
let surface = TmuxPaneSurface(
id: p, cols: cols, rows: rows,
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } }
onViewport: { [weak self] c, r, cw, ch in
Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) }
}
)
surfaceByPane[p] = surface
win.panes[p] = surface
@@ -293,22 +322,6 @@ final class TmuxController: ObservableObject {
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .listSessions:
sessions = r.lines.compactMap { line in
let f = line.components(separatedBy: Self.fieldSep)
guard f.count >= 3 else { return nil }
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
return TmuxSessionInfo(name: f[0],
windows: Int(f[1]) ?? 1,
isAttached: f[2] == "1",
lastActivity: activity)
}
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
NSLog("TMUXDBG currentSession=\(name)")
}
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
@@ -379,7 +392,10 @@ final class TmuxController: ObservableObject {
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
// MARK: - / / resizeUI-4
// MARK: - / resizeUI-4
// app split-window / join-pane
// pane **** attach layout
/// tmux ****
/// tmux `%exit` `onExit`
@@ -388,24 +404,6 @@ final class TmuxController: ObservableObject {
/// tmux
func killSession() { sendCommand("kill-session") }
/// tmux `-h` 线`-v`
/// tmux `%layout-change` reconcile pane surface
/// capture-pane `applyLayout` / `ensurePane`
func splitWindow(vertical: Bool, joinSession: String? = nil) {
if let s = joinSession {
// window window panetmux tmux
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
} else {
sendCommand("split-window \(vertical ? "-h" : "-v")")
}
}
/// tmux window window detached
func splitIntoNewSession(vertical: Bool, name: String) {
sendCommand("new-session -d -s \(name)")
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
}
/// pane ****
/// `%layout-change` frame "线 b"
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
@@ -413,13 +411,6 @@ final class TmuxController: ObservableObject {
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
}
/// tmux server detached `sessions`
func listSessions() {
let sep = Self.fieldSep
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
pending.append(.listSessions)
}
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }

View File

@@ -150,18 +150,6 @@ enum UIPreviewFixture {
]),
]
/// attached + detached稿 `3b`
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
// timeIntervalSinceNow
[
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -86400)),
]
}
/// `-txOpenAsHost 1`
static func hostFromLaunchArgs() -> SavedHost? {
let d = UserDefaults.standard
@@ -186,8 +174,7 @@ enum UIPreviewFixture {
/// GUIidb companion
/// URL `AppRouter`/`SessionManager`
///
/// `home` · `minimize` · `close`() · `close-now`() · `split/v|h`
/// · `split-now/v|h`() · `resize/<cols>`(resize-pane)
/// `home` · `minimize` · `close`() · `close-now`() · `resize/<cols>`(resize-pane)
/// · `pick/<>` · `pick-new/<>` · `pick-native`
/// · `dismiss` · `session/<>`1 · `sidebar/toggle|pin`
@MainActor
@@ -204,16 +191,8 @@ enum UIPreviewFixture {
case "close-now":
//
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
case "split":
guard router.route.sessionID != nil else { return false }
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
case "dismiss":
router.dismissModal()
case "split-now":
// split-window
guard let id = router.route.sessionID, let s = manager.session(id),
let tmux = s.tmuxController else { return false }
tmux.splitWindow(vertical: path.dropFirst().first != "h")
case "pick":
// attach `ui/pick/<name>`
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),