feat: 修复 3 个渲染/tmux 显示 bug + UI 产品化第一波(对标 Moshi)
渲染/tmux 修复(e2e 复现+回归,模拟器 idb 点击+截图取证): - 终端底部残影:ghostty iOS 释放 surface 不摘 IOSurfaceLayer(上游缺陷), platformSetup 清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子摘孤儿层 - tmux 切 tab 切回旧 tab 内容消失:改所有窗口常驻挂载(ZStack+opacity+hitTesting), surface 不释放、内容保留、后台窗口持续收 %output - tmux pane 内容钉底/顶部残行:pane grid 由容器几何定(tab 条吃高度),75 行快照喂 73 行 溢出上滚;几何测量上移 TmuxTabbedView + attach kickoff 双条件(attachAcked∧containerKnown) + capture-pane 铺快照前剔尾空行 UI 产品化(对标 Moshi getmoshi.app:深蓝黑 + 终端绿 + Catppuccin): - 设计系统 Theme.swift:TXPalette(色角色 struct)+TXThemeManager(环境注入),颜色封装供主题系统 - 统一主题:抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),一套同时驱动 app+终端配色, 设置里选主题 app 与终端一起变色 + UserDefaults 持久化 - 主屏/主机管理页 HomeView:HostCard 主机卡 + FAB 加主机(AddHostSheet) + 设置(主题选择器), HostStore 本地 JSON 持久化(密码暂存/TODO Keychain),点卡片 connect(to:) - 终端外壳 chrome TerminalTopBar:圆形返回钮 + 会话标题 + SSH/mosh 传输徽章 + 状态提示, tmux tab 绿 pill 化,底部工具栏深色自适应,终端默认 Catppuccin Mocha iPad mini(A17 Pro) live 端到端验证:加主机→连接→终端→切 tab→分屏点焦点→主题切换同步。 详见 docs/HANDOFF.md §10。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,99 +1,434 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SSHTerminalModel()
|
||||
@StateObject private var hostStore = HostStore()
|
||||
@StateObject private var theme = TXThemeManager()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||||
// 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。
|
||||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||||
TsnetProbeView(authKey: tsnetKey)
|
||||
} else {
|
||||
Group {
|
||||
if model.showsTerminal {
|
||||
TerminalScreen(model: model)
|
||||
} else {
|
||||
ConnectionForm(model: model)
|
||||
Group {
|
||||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||||
TsnetProbeView(authKey: tsnetKey)
|
||||
} else if model.showsTerminal {
|
||||
TerminalScreen(model: model)
|
||||
} else {
|
||||
HomeView(model: model, store: hostStore)
|
||||
}
|
||||
}
|
||||
.environmentObject(theme)
|
||||
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
|
||||
.onChange(of: theme.palette.id) { _, _ in
|
||||
// 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给已连接的 surface。
|
||||
model.applyTerminalTheme(theme.palette.terminalTheme)
|
||||
}
|
||||
.onAppear { model.autoConnectIfConfigured() }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background: model.enterBackground()
|
||||
case .active: model.enterForeground()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||||
get: { model.hostKeyMismatch != nil },
|
||||
set: { if !$0 { model.dismissHostKeyMismatch() } }
|
||||
), presenting: model.hostKeyMismatch) { _ in
|
||||
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
|
||||
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
|
||||
} message: { info in
|
||||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 主屏:主机列表(对标 Moshi)。卡片式主机 + 右下 FAB 加主机 + 右上设置入口。
|
||||
struct HomeView: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
@ObservedObject var store: HostStore
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@State private var showAdd = false
|
||||
@State private var showSettings = false
|
||||
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
p.bg.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||||
sectionHeader("连接")
|
||||
if store.hosts.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(store.hosts) { host in
|
||||
HostCard(host: host) { model.connect(to: host) }
|
||||
.contextMenu {
|
||||
Button(role: .destructive) { store.remove(host) } label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isBusy || model.phaseText.hasPrefix("失败") {
|
||||
statusRow
|
||||
}
|
||||
}
|
||||
.padding(TX.Space.m)
|
||||
.padding(.bottom, 90) // 给 FAB 留空
|
||||
}
|
||||
|
||||
fab
|
||||
}
|
||||
.background(p.bg)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("terminalX")
|
||||
.font(TX.Font.mono(17, .semibold))
|
||||
.foregroundStyle(p.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
.foregroundStyle(p.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { model.autoConnectIfConfigured() }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background: model.enterBackground()
|
||||
case .active: model.enterForeground()
|
||||
default: break
|
||||
.toolbarBackground(p.bg, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
.tint(p.accent)
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsStubView()
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: TX.Space.s) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(p.textTertiary)
|
||||
Text("还没有主机")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
Text("点击右下角 + 添加一台服务器")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(p.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
|
||||
/// 进行中(连接/认证/重连)——决定是否转圈。
|
||||
private var isBusy: Bool {
|
||||
model.phaseText.hasSuffix("…") || model.phaseText.contains("重连")
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
|
||||
Text(model.phaseText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var fab: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button { showAdd = true } label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(p.bg)
|
||||
.frame(width: 60, height: 60)
|
||||
.background(p.accent, in: Circle())
|
||||
.shadow(color: p.accent.opacity(0.35), radius: 12, y: 4)
|
||||
}
|
||||
}
|
||||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||||
get: { model.hostKeyMismatch != nil },
|
||||
set: { if !$0 { model.dismissHostKeyMismatch() } }
|
||||
), presenting: model.hostKeyMismatch) { _ in
|
||||
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
|
||||
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
|
||||
} message: { info in
|
||||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||||
.padding(TX.Space.l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。
|
||||
struct ConnectionForm: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
/// 主机卡片:服务器图标 + 状态点 / 名称 / user@ip:port(等宽)/ chevron。
|
||||
struct HostCard: View {
|
||||
let host: SavedHost
|
||||
let onTap: () -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: TX.Space.m) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
|
||||
if host.useMosh {
|
||||
Circle().fill(p.mosh).frame(width: 9, height: 9)
|
||||
.overlay(Circle().stroke(p.surface, lineWidth: 2))
|
||||
.offset(x: 3, y: -3)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(host.name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(p.textPrimary)
|
||||
Text(host.addressLine)
|
||||
.font(TX.Font.mono(13))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(p.textTertiary)
|
||||
}
|
||||
.padding(TX.Space.m)
|
||||
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加主机表单(深色样式)。
|
||||
struct AddHostSheet: View {
|
||||
let onSave: (SavedHost) -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var host = ""
|
||||
@State private var port = "22"
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var useMosh = false
|
||||
|
||||
private var p: TXPalette { theme.palette }
|
||||
private var canSave: Bool { !host.isEmpty && !username.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("SSH 直连 (M0)") {
|
||||
TextField("主机 / IP", text: $host)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("端口", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("用户名", text: $username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
SecureField("密码", text: $password)
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
model.connect(host: host, port: Int(port) ?? 22,
|
||||
username: username, password: password)
|
||||
} label: {
|
||||
Text("连接").frame(maxWidth: .infinity)
|
||||
ZStack {
|
||||
p.bg.ignoresSafeArea()
|
||||
Form {
|
||||
Section {
|
||||
field("名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
|
||||
field("主机 / IP", text: $host)
|
||||
field("端口", text: $port, keyboard: .numberPad)
|
||||
field("用户名", text: $username)
|
||||
SecureField("密码", text: $password)
|
||||
.foregroundStyle(p.textPrimary)
|
||||
.listRowBackground(p.surface)
|
||||
Toggle("使用 mosh", isOn: $useMosh)
|
||||
.tint(p.accent)
|
||||
.foregroundStyle(p.textPrimary)
|
||||
.listRowBackground(p.surface)
|
||||
}
|
||||
.disabled(host.isEmpty || username.isEmpty)
|
||||
}
|
||||
Section("状态") {
|
||||
Text(model.phaseText)
|
||||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
|
||||
if !model.banner.isEmpty {
|
||||
Text(model.banner).font(.caption).foregroundStyle(.secondary)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.navigationTitle("添加主机")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }.tint(p.textSecondary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") {
|
||||
onSave(SavedHost(
|
||||
name: name.isEmpty ? host : name,
|
||||
host: host, port: Int(port) ?? 22,
|
||||
username: username, password: password, useMosh: useMosh))
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!canSave)
|
||||
.tint(p.accent)
|
||||
}
|
||||
}
|
||||
.navigationTitle("terminalX")
|
||||
.toolbarBackground(p.bg, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
.tint(p.accent)
|
||||
}
|
||||
|
||||
private func field(_ title: String, text: Binding<String>,
|
||||
placeholder: String = "", keyboard: UIKeyboardType = .default) -> some View {
|
||||
TextField(placeholder.isEmpty ? title : placeholder, text: text)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.foregroundStyle(p.textPrimary)
|
||||
.listRowBackground(p.surface)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页占位(后续填充:终端主题/字体/工具栏/关于)。
|
||||
struct SettingsStubView: View {
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
p.bg.ignoresSafeArea()
|
||||
List {
|
||||
Section("主题(app 与终端统一)") {
|
||||
ForEach(TXPalette.all) { pal in
|
||||
Button { theme.apply(pal) } label: { themeRow(pal) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listRowBackground(p.surface)
|
||||
Section("终端") {
|
||||
row("textformat", "字体与大小", value: "系统等宽")
|
||||
}
|
||||
.listRowBackground(p.surface)
|
||||
Section("关于") {
|
||||
row("info.circle", "terminalX", value: "0.0.1")
|
||||
}
|
||||
.listRowBackground(p.surface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.foregroundStyle(p.textPrimary)
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }.tint(p.accent)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(p.bg, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
.tint(p.accent)
|
||||
}
|
||||
|
||||
/// 主题行:色板预览(底/面/强调)+ 名称 + 选中勾。
|
||||
private func themeRow(_ pal: TXPalette) -> some View {
|
||||
HStack(spacing: TX.Space.m) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7).fill(pal.bg)
|
||||
HStack(spacing: 3) {
|
||||
Circle().fill(pal.accent).frame(width: 7, height: 7)
|
||||
Circle().fill(pal.mosh).frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 28)
|
||||
.overlay(RoundedRectangle(cornerRadius: 7).stroke(p.border, lineWidth: 1))
|
||||
|
||||
Text(pal.name).foregroundStyle(p.textPrimary)
|
||||
Spacer()
|
||||
if pal.id == theme.palette.id {
|
||||
Image(systemName: "checkmark").foregroundStyle(p.accent).font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ icon: String, _ title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon).foregroundStyle(p.textSecondary).frame(width: 26)
|
||||
Text(title).foregroundStyle(p.textPrimary)
|
||||
Spacer()
|
||||
Text(value).foregroundStyle(p.textSecondary).font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TerminalScreen: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
if let tmux = model.tmuxController {
|
||||
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
||||
} else {
|
||||
RawTerminalView(model: model) // 普通单终端
|
||||
VStack(spacing: 0) {
|
||||
TerminalTopBar(model: model)
|
||||
Divider().overlay(p.border)
|
||||
Group {
|
||||
if let tmux = model.tmuxController {
|
||||
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
||||
} else {
|
||||
RawTerminalView(model: model) // 普通单终端
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(p.bg.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端顶部状态行:返回主屏 · 会话标题 · 状态提示 · 传输徽章(SSH/mosh)。
|
||||
struct TerminalTopBar: View {
|
||||
@ObservedObject var model: SSHTerminalModel
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
private var connected: Bool { model.phaseText == "已连接" || model.phaseText == "mosh 已连接" }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: TX.Space.s) {
|
||||
Button { model.close() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(p.surface, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(model.connectionTitle.isEmpty ? "终端" : model.connectionTitle)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(p.textPrimary)
|
||||
.lineLimit(1)
|
||||
if !connected {
|
||||
Text(model.phaseText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
transportBadge
|
||||
}
|
||||
.padding(.horizontal, TX.Space.m)
|
||||
.padding(.vertical, TX.Space.s)
|
||||
.background(p.bg)
|
||||
}
|
||||
|
||||
private var transportBadge: some View {
|
||||
let isMosh = model.moshEngaged
|
||||
let color = isMosh ? p.mosh : p.accent
|
||||
return HStack(spacing: 5) {
|
||||
Circle().fill(connected ? color : p.offline).frame(width: 6, height: 6)
|
||||
Text(isMosh ? "mosh" : "SSH")
|
||||
.font(TX.Font.mono(11, .medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 9).padding(.vertical, 5)
|
||||
.background(color.opacity(0.13), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,15 +440,6 @@ struct RawTerminalView: View {
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: model.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.overlay(alignment: .top) {
|
||||
if model.phaseText != "已连接", !model.banner.isEmpty {
|
||||
Text(model.banner)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
@@ -126,11 +452,14 @@ struct RawTerminalView: View {
|
||||
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
||||
struct TmuxTabbedView: View {
|
||||
@ObservedObject var controller: TmuxController
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: TX.Space.xs) {
|
||||
ForEach(controller.windows) { win in
|
||||
TmuxTabChip(window: win,
|
||||
isActive: win.id == controller.activeWindowID) {
|
||||
@@ -138,19 +467,43 @@ struct TmuxTabbedView: View {
|
||||
}
|
||||
}
|
||||
Button { controller.newWindow() } label: {
|
||||
Image(systemName: "plus").padding(.horizontal, 6)
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(p.textSecondary)
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
.padding(.horizontal, TX.Space.s).padding(.vertical, TX.Space.xs)
|
||||
}
|
||||
.background(.thinMaterial)
|
||||
Divider()
|
||||
if let win = controller.activeWindow {
|
||||
TmuxWindowView(window: win, controller: controller)
|
||||
.id(win.id.raw)
|
||||
} else {
|
||||
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
|
||||
.background(p.bg)
|
||||
Divider().overlay(p.border)
|
||||
Group {
|
||||
if controller.windows.isEmpty {
|
||||
Spacer(); Text("无 tmux 窗口").foregroundStyle(p.textSecondary); Spacer()
|
||||
} else {
|
||||
// 所有窗口常驻挂载、仅激活窗口可见:切 tab 不销毁其它窗口的 pane 视图,
|
||||
// 故 ghostty surface 不被释放、grid 内容保留(否则切回旧 tab 会空白),
|
||||
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
|
||||
ZStack {
|
||||
ForEach(controller.windows) { win in
|
||||
TmuxWindowView(window: win, controller: controller)
|
||||
.opacity(win.id == controller.activeWindowID ? 1 : 0)
|
||||
.allowsHitTesting(win.id == controller.activeWindowID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 容器几何测量放在这一层(windows 为空时同样占满剩余空间、尺寸相同):
|
||||
// pane grid 由容器像素决定,须在 attach 前拿到真实容器格子;放 TmuxWindowView 会与
|
||||
// "windows 靠 list-windows 才有" 形成鸡生蛋死锁(见 TmuxController.kickoffIfReady)。
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||||
.onChange(of: geo.size) { _, s in
|
||||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,14 +512,19 @@ struct TmuxTabChip: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let isActive: Bool
|
||||
let onTap: () -> Void
|
||||
@EnvironmentObject var theme: TXThemeManager
|
||||
private var p: TXPalette { theme.palette }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Text(window.title)
|
||||
.font(.caption).lineLimit(1)
|
||||
.font(TX.Font.mono(13, isActive ? .semibold : .regular)).lineLimit(1)
|
||||
.foregroundStyle(isActive ? p.accent : p.textSecondary)
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
|
||||
in: RoundedRectangle(cornerRadius: 8))
|
||||
.background(isActive ? p.accentSoft : p.surface,
|
||||
in: RoundedRectangle(cornerRadius: TX.Radius.chip))
|
||||
.overlay(RoundedRectangle(cornerRadius: TX.Radius.chip)
|
||||
.stroke(isActive ? p.accent.opacity(0.4) : p.border, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -176,7 +534,6 @@ struct TmuxTabChip: View {
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
@@ -205,11 +562,7 @@ struct TmuxWindowView: View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
// iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。
|
||||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||||
.onChange(of: geo.size) { _, s in
|
||||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||||
}
|
||||
// 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user