按 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>
98 lines
3.6 KiB
Swift
98 lines
3.6 KiB
Swift
import SwiftUI
|
||
|
||
/// 顶层去处。设计定稿只有两处落点:首页(主机管理)与终端。
|
||
///
|
||
/// 关键:路由与**会话生命周期解耦** —— 回首页/最小化只改 route,会话照跑
|
||
/// (会话 surface 由 `SessionCanvas` 常驻挂载,见 ContentView)。
|
||
enum AppRoute: Equatable {
|
||
case home
|
||
case terminal(UUID)
|
||
|
||
var sessionID: UUID? {
|
||
if case .terminal(let id) = self { return id }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// 侧边栏三态。同一条栏,元素不增不减,只是展示密度不同。
|
||
enum SidebarState: Equatable {
|
||
/// 56pt 轨道:只显图标与两字缩写
|
||
case collapsed
|
||
/// 288pt 面板**盖住**轨道,右侧终端压遮罩;点终端任意处收起。终端不 reflow、不触发 PTY resize。
|
||
case overlay
|
||
/// 288pt 钉在左侧:遮罩消失,终端变窄。**只在钉住/解除时各 resize 一次**。
|
||
case pinned
|
||
|
||
var isExpanded: Bool { self != .collapsed }
|
||
|
||
/// 该状态下侧边栏占据的布局宽度(overlay 是浮层,不占位)。
|
||
var layoutWidth: CGFloat {
|
||
switch self {
|
||
case .collapsed, .overlay: TX.Layout.railWidth
|
||
case .pinned: TX.Layout.sidebarWidth
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动
|
||
/// 都要能打开它,视图内部状态触达不到。
|
||
enum AppModal: Equatable {
|
||
/// 关闭确认(3c)
|
||
case closeConfirm
|
||
/// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D)
|
||
case splitMenu(vertical: Bool)
|
||
}
|
||
|
||
/// 导航状态持有者。视图只读它、只经方法改它。
|
||
@MainActor
|
||
final class AppRouter: ObservableObject {
|
||
@Published var route: AppRoute = .home
|
||
@Published var sidebar: SidebarState = .collapsed
|
||
/// 当前模态浮层(关闭确认 / 分屏菜单)。
|
||
@Published var modal: AppModal?
|
||
|
||
/// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。
|
||
/// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。
|
||
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
|
||
|
||
private func animateSidebar(_ change: () -> Void) {
|
||
withAnimation(Self.sidebarAnimation, change)
|
||
}
|
||
|
||
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
|
||
func enter(_ sessionID: UUID) {
|
||
route = .terminal(sessionID)
|
||
}
|
||
|
||
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。
|
||
func goHome() {
|
||
modal = nil
|
||
route = .home
|
||
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
||
}
|
||
|
||
/// 开合侧边栏(⌘\ / 开合键)。折叠 ⇄ 遮罩;Pin 态视作展开,收起回折叠。
|
||
func toggleSidebar() {
|
||
animateSidebar {
|
||
switch sidebar {
|
||
case .collapsed: sidebar = .overlay
|
||
case .overlay, .pinned: sidebar = .collapsed
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pin 切换(只在展开态出现)。钉住/解除各触发一次终端 resize。
|
||
func togglePin() {
|
||
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
|
||
}
|
||
|
||
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
||
func present(_ modal: AppModal) { self.modal = modal }
|
||
func dismissModal() { modal = nil }
|
||
|
||
/// 点终端任意处:遮罩态收起(Pin 态不受影响)。
|
||
func dismissOverlaySidebar() {
|
||
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
||
}
|
||
}
|