Files
terminalX/apps/TerminalX/iOS/SessionPickerSheet.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连
- 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用
- 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView

首页/弹框减法:
- 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」
- 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻)
- 会话卡加 ✕ + 复用终端页同款二次确认
- 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近)

终端页修复:
- 侧边栏会话列表改创建顺序,切换不再重排抖动
- 焦点高亮上移到 pane 容器边框(四边完整,去负 padding)
- 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次
- 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦;
  vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:49:58 +08:00

202 lines
8.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import TXCore
///
///
/// **** sheet + ""
/// Herdr/ Header `CloseConfirmDialog`
/// TXModalScrim + surfaceDark + borderStrong + dialog
///
/// ** tmux **· ** tmux ** · ****
/// ** tmux **
/// =
struct SessionPickerDialog: View {
/// ""
let host: String
let choice: TmuxSessionChoice
let onAttach: (String) -> Void
let onCreate: (String) -> Void
let onNative: () -> Void
///
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var newName = ""
@State private var creating = false
@FocusState private var nameFocused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.m) {
title
if choice.sessions.isEmpty {
emptyState
} else {
sessionList
}
newSessionRow
nativeRow
}
.padding(TX.Space.l)
.frame(width: 480)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
private var title: some View {
VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
}
// MARK: -
private var sessionList: some View {
//
Group {
if choice.sessions.count > 4 {
ScrollView { sessionRows }
.frame(height: 264)
} else {
sessionRows
}
}
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
private var sessionRows: some View {
VStack(spacing: 0) {
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
Button { onAttach(s.name) } label: {
sessionRow(s)
}
.buttonStyle(.txPress)
if index < choice.sessions.count - 1 {
Rectangle().fill(p.divider).frame(height: 1)
.padding(.horizontal, TX.Space.m)
}
}
}
}
private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: TX.Space.s) {
Text(s.name)
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
// tmux
TXBadge(text: "活跃", emphasized: true)
if s.isAttached {
TXBadge(text: "已被接入")
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textFaint)
}
Text(subtitle(s))
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s + 2)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func subtitle(_ s: TmuxSessionProbe.Session) -> String {
var parts = ["\(s.windows) 个窗口"]
if let t = s.lastActivity { parts.append(SessionCard.relative(t)) }
return parts.joined(separator: " · ")
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: 6) {
Text("这台主机上还没有 tmux 会话")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineSpacing(3)
}
.padding(TX.Space.m)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
// MARK: - tmux
@ViewBuilder private var newSessionRow: some View {
if creating {
HStack(spacing: TX.Space.s) {
TXSearchField(text: $newName, placeholder: choice.defaultNewName)
.focused($nameFocused)
TXButton(title: "创建", emphasized: true) {
let name = newName.trimmingCharacters(in: .whitespaces)
onCreate(name.isEmpty ? choice.defaultNewName : name)
}
TXButton(title: "取消") { creating = false }
}
} else {
Button {
creating = true
nameFocused = true
} label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
Text("新建 tmux 会话…")
.font(TX.Font.mono(13, .medium))
Spacer(minLength: 0)
}
.foregroundStyle(TXAccent.textBright)
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
}
}
// MARK: - ""
private var nativeRow: some View {
Button(action: onNative) {
HStack(spacing: TX.Space.s) {
Image(systemName: "terminal")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.text3)
Text("原生终端")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text("不经过 tmux")
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.bg, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
}