按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
351 lines
15 KiB
Swift
351 lines
15 KiB
Swift
import SwiftUI
|
||
|
||
/// 主机列表页(split 详情)。点卡片连接;右上 + 新建;上下文菜单编辑/删除。
|
||
struct HostsListView: View {
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var hostStore: HostStore
|
||
@ObservedObject var credStore: FileCredentialStore
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@State private var editing: SavedHost?
|
||
@State private var showEditor = false
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
p.bg.ignoresSafeArea()
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||
if hostStore.hosts.isEmpty {
|
||
TXEmptyState(icon: "server.rack", title: "还没有主机",
|
||
subtitle: "点击右上角 + 添加一台服务器")
|
||
} else {
|
||
ForEach(hostStore.hosts) { host in
|
||
HostCard(host: host,
|
||
credName: credName(for: host),
|
||
tailscaleName: tailscaleName(for: host)) {
|
||
// 设计定稿:建好即进终端(连接进度显示在终端页);已有该主机会话则复用。
|
||
let s = manager.open(host: host)
|
||
router.enter(s.id)
|
||
}
|
||
.contextMenu {
|
||
Button { editing = host; showEditor = true } label: {
|
||
Label("编辑", systemImage: "pencil")
|
||
}
|
||
Button(role: .destructive) { hostStore.remove(host) } label: {
|
||
Label("删除", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 独立订阅焦点会话:manager 是嵌套 ObservableObject,会话内部变化不冒泡到这里。
|
||
if let s = manager.focused { SessionStatusRow(session: s) }
|
||
}
|
||
.padding(TX.Space.m)
|
||
}
|
||
}
|
||
.navigationTitle("主机")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbarBackground(p.bg, for: .navigationBar)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
|
||
}
|
||
}
|
||
.sheet(isPresented: $showEditor) {
|
||
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in
|
||
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
|
||
else { hostStore.add(host) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private func credName(for host: SavedHost) -> String? {
|
||
if case .credential(let id) = host.auth {
|
||
return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)"
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func tailscaleName(for host: SavedHost) -> String? {
|
||
guard let id = host.tailscaleID else { return nil }
|
||
return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)"
|
||
}
|
||
|
||
}
|
||
|
||
/// 焦点会话的连接进度/失败提示(连接中或失败时才出现)。
|
||
/// 单独成视图以 `@ObservedObject` 直接订阅该会话——`SessionManager` 不会转发会话内部变化。
|
||
struct SessionStatusRow: View {
|
||
@ObservedObject var session: TerminalSession
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var isBusy: Bool {
|
||
session.phaseText.hasSuffix("…") || session.phaseText.contains("重连")
|
||
}
|
||
|
||
var body: some View {
|
||
if isBusy || session.phaseText.hasPrefix("失败") {
|
||
HStack(spacing: TX.Space.s) {
|
||
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
|
||
Text(session.phaseText)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary)
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 主机卡片:图标 + mosh 点 / 名称 / 地址(等宽)/ 凭据·Tailscale 徽章 / chevron。
|
||
struct HostCard: View {
|
||
let host: SavedHost
|
||
let credName: String?
|
||
let tailscaleName: String?
|
||
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)
|
||
if credName != nil || tailscaleName != nil {
|
||
HStack(spacing: TX.Space.xs) {
|
||
if let c = credName { badge("key.fill", c, p.accent) }
|
||
if let t = tailscaleName { badge("network", t, p.mosh) }
|
||
}
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: icon).font(.system(size: 9))
|
||
Text(text).font(.system(size: 11, weight: .medium)).lineLimit(1)
|
||
}
|
||
.foregroundStyle(color)
|
||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||
.background(color.opacity(0.13), in: Capsule())
|
||
}
|
||
}
|
||
|
||
/// 主机编辑页:基本信息 + 认证(直接输入 / 选 Keychain)+ 网络(Direct / Tailscale)+ 选项。
|
||
struct HostEditorView: View {
|
||
let existing: SavedHost?
|
||
@ObservedObject var credStore: FileCredentialStore
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
/// 已有分组(供一键选,避免录入同义不同名)。
|
||
var existingGroups: [String] = []
|
||
let onSave: (SavedHost) -> Void
|
||
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private enum AuthMode: Hashable { case inline, credential }
|
||
|
||
@State private var name = ""
|
||
@State private var host = ""
|
||
@State private var port = "22"
|
||
@State private var authMode: AuthMode = .inline
|
||
@State private var inlineUser = ""
|
||
@State private var inlinePassword = ""
|
||
@State private var selectedCredentialID: UUID?
|
||
@State private var tailscaleID: UUID?
|
||
@State private var useMosh = false
|
||
@State private var showNewCredential = false
|
||
/// 分组(首页筛选 chips)与系统描述(主机卡副行)——真实数据只能从这里录入。
|
||
@State private var group = ""
|
||
@State private var os = ""
|
||
@State private var useTmux = true
|
||
@State private var tmuxSession = ""
|
||
|
||
private var p: TXPalette { theme.palette }
|
||
private var canSave: Bool {
|
||
guard !host.isEmpty else { return false }
|
||
switch authMode {
|
||
case .inline: return !inlineUser.isEmpty
|
||
case .credential: return selectedCredentialID != nil
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
p.bg.ignoresSafeArea()
|
||
Form {
|
||
Section("基本") {
|
||
TXTextField(title: "名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
|
||
TXTextField(title: "主机 / IP", text: $host)
|
||
TXTextField(title: "端口", text: $port, keyboard: .numberPad)
|
||
}
|
||
Section {
|
||
TXTextField(title: "分组", text: $group, placeholder: "生产 / 家里 / 云(可空)")
|
||
if !existingGroups.isEmpty {
|
||
// 已有分组一键选,避免同义不同名把 chips 撑爆。
|
||
HStack(spacing: TX.Space.xs) {
|
||
ForEach(existingGroups, id: \.self) { g in
|
||
TXChip(text: g, isSelected: group == g) {
|
||
group = (group == g) ? "" : g
|
||
}
|
||
}
|
||
}
|
||
.txRow(p)
|
||
}
|
||
TXTextField(title: "系统", text: $os, placeholder: "macOS · M4 Max(可空)")
|
||
} header: {
|
||
Text("归类(首页展示用)")
|
||
} footer: {
|
||
Text("分组决定首页「全部主机」的筛选 chips;系统只作卡片副行展示,不参与连接。")
|
||
}
|
||
authSection
|
||
networkSection
|
||
Section {
|
||
Toggle("自动进入 tmux", isOn: $useTmux).tint(p.accent).txRow(p)
|
||
if useTmux {
|
||
TXTextField(title: "tmux 会话名", text: $tmuxSession, placeholder: "main")
|
||
}
|
||
Toggle("使用 mosh", isOn: $useMosh).tint(p.accent).txRow(p)
|
||
} header: {
|
||
Text("会话")
|
||
} footer: {
|
||
Text(useTmux
|
||
? "登录后执行 tmux -CC new -A -s <会话名>:同名会话存在就接回,否则新建。服务器没装 tmux 会自动降级为客户端窗口并给安装引导。"
|
||
: "不进 tmux:会话由 App 维持,关掉 App 后服务端不会继续跑(mosh 仍能兜断线)。")
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
.navigationTitle(existing == nil ? "添加主机" : "编辑主机")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("取消") { dismiss() }.tint(p.textSecondary)
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
|
||
}
|
||
}
|
||
.toolbarBackground(p.bg, for: .navigationBar)
|
||
.toolbarBackground(.visible, for: .navigationBar)
|
||
.sheet(isPresented: $showNewCredential) {
|
||
CredentialEditorView(existing: nil, store: credStore) { newCred in
|
||
selectedCredentialID = newCred.id
|
||
authMode = .credential
|
||
}
|
||
}
|
||
.onAppear(perform: populate)
|
||
}
|
||
.tint(p.accent)
|
||
}
|
||
|
||
@ViewBuilder private var authSection: some View {
|
||
Section("认证") {
|
||
Picker("方式", selection: $authMode) {
|
||
Text("直接输入").tag(AuthMode.inline)
|
||
Text("从 Keychain 选择").tag(AuthMode.credential)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.txRow(p)
|
||
|
||
if authMode == .inline {
|
||
TXTextField(title: "用户名", text: $inlineUser)
|
||
TXTextField(title: "密码", text: $inlinePassword, secure: true)
|
||
} else {
|
||
Picker("凭据", selection: $selectedCredentialID) {
|
||
Text("未选择").tag(UUID?.none)
|
||
ForEach(credStore.credentials) { c in
|
||
Text("\(c.name) · \(c.username)").tag(UUID?.some(c.id))
|
||
}
|
||
}
|
||
.txRow(p)
|
||
Button { showNewCredential = true } label: {
|
||
Label("新建凭据", systemImage: "plus.circle")
|
||
}
|
||
.txRow(p)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var networkSection: some View {
|
||
Section("网络(出口)") {
|
||
Picker("经由", selection: $tailscaleID) {
|
||
Text("Direct(直连)").tag(UUID?.none)
|
||
ForEach(tailscaleStore.connections) { conn in
|
||
Text(conn.name).tag(UUID?.some(conn.id))
|
||
}
|
||
}
|
||
.txRow(p)
|
||
if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey {
|
||
Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。")
|
||
.font(.system(size: 12)).foregroundStyle(p.warning).txRow(p)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func populate() {
|
||
guard let h = existing else { return }
|
||
name = h.name; host = h.host; port = String(h.port)
|
||
useMosh = h.useMosh; tailscaleID = h.tailscaleID
|
||
group = h.group ?? ""; os = h.os ?? ""
|
||
useTmux = h.useTmux; tmuxSession = h.tmuxSession ?? ""
|
||
switch h.auth {
|
||
case .inlinePassword(let u, let pw):
|
||
authMode = .inline; inlineUser = u; inlinePassword = pw
|
||
case .credential(let id):
|
||
authMode = .credential; selectedCredentialID = id
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let auth: SavedHost.AuthRef
|
||
switch authMode {
|
||
case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword)
|
||
case .credential:
|
||
guard let id = selectedCredentialID else { return }
|
||
auth = .credential(id: id)
|
||
}
|
||
let saved = SavedHost(
|
||
id: existing?.id ?? UUID(),
|
||
name: name.isEmpty ? host : name,
|
||
host: host, port: Int(port) ?? 22,
|
||
useMosh: useMosh, useTmux: useTmux,
|
||
tmuxSession: tmuxSession.isEmpty ? nil : tmuxSession.trimmingCharacters(in: .whitespaces),
|
||
auth: auth, tailscaleID: tailscaleID,
|
||
group: group.isEmpty ? nil : group.trimmingCharacters(in: .whitespaces),
|
||
os: os.isEmpty ? nil : os.trimmingCharacters(in: .whitespaces))
|
||
onSave(saved)
|
||
dismiss()
|
||
}
|
||
}
|