Files
Termind/TFI/App/MobileRootView.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

201 lines
8.6 KiB
Swift

import SwiftUI
import TermindPresentation
/// A window retains its feature stacks while the system adapts tabs into a sidebar.
struct MobileRootView: View {
let app: MobileAppModel
private let features: MobileFeatureRegistry
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var scene: MobileSceneModel
private let pendingQuickAction: MobileHomeScreenQuickAction?
private let consumePendingQuickAction: () -> MobileHomeScreenQuickAction?
init(
app: MobileAppModel,
scene: MobileSceneModel,
features: MobileFeatureRegistry = .standard(),
pendingQuickAction: MobileHomeScreenQuickAction? = nil,
consumePendingQuickAction: @escaping () -> MobileHomeScreenQuickAction? = { nil }
) {
self.app = app
self.features = features
self.pendingQuickAction = pendingQuickAction
self.consumePendingQuickAction = consumePendingQuickAction
_scene = State(initialValue: scene)
}
var body: some View {
content.overlay { RemoteSessionClosePromptView(coordinator: app.persistentClose) }
}
private var content: some View {
featureTabs
.background { MobileWorkspaceNumberShortcuts(scene: scene) }
.modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications))
.onChange(of: scene.visibleAssistantConversationID, initial: true) { app.refreshAssistantReadState() }
.environment(\.singleLineInputAdapter, { AnyView(MobileSingleLineFieldInput()) })
.sheet(isPresented: Bindable(app.pro).projectedValue.isPaywallPresented) {
MobileProView(pro: app.pro)
}
.sheet(item: $scene.renamingWorkspace) { descriptor in
MobileWorkspaceRenameView(descriptor: descriptor) { title in
scene.workspaceSession.renameWorkspace(descriptor.id, to: title)
}
}
.sheet(
isPresented: Binding(
get: { scene.agentForwarding.current != nil },
set: { if !$0 { scene.agentForwarding.cancelAll() } }
)
) {
if let challenge = scene.agentForwarding.current {
MobileAgentForwardingSheet(interaction: scene.agentForwarding, request: challenge)
.id(challenge.id)
}
}
.onDisappear { scene.agentForwarding.isActive = false }
.onChange(of: scenePhase, initial: true) { _, phase in
scene.agentForwarding.isActive = phase != .background
app.sceneChanged(scene.id, phase: phase, workspaceSessionID: scene.workspaceSession.id)
if phase == .active { app.connectionHistory.refreshMenuDate() }
MobileHomeScreenQuickActions.refresh(app: app)
}
.onChange(of: app.connectionHistory.revision, initial: true) { _, _ in
MobileHomeScreenQuickActions.refresh(app: app)
}
.onChange(of: app.library.hosts) { _, _ in
MobileHomeScreenQuickActions.refresh(app: app)
}
.onChange(of: app.library.identities) { _, _ in
MobileHomeScreenQuickActions.refresh(app: app)
}
.onChange(of: pendingQuickAction, initial: true) { _, action in
guard action != nil, let action = consumePendingQuickAction() else { return }
_ = scene.perform(action)
}
.onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
.onChange(of: scene.workspaceSession.workspaces.map(\.id)) { _, workspaceIDs in
scene.reconcileWorkspaceIDs(workspaceIDs)
}
.onDisappear { app.removeScene(scene.id) }
.onOpenURL { url in
MobileWorkspaceSceneRouting.reconcile(app: app, activeSessionID: scene.workspaceSession.id)
scene.open(url)
}
.focusedSceneValue(\.mobileScene, scene)
.modifier(MobileHistoryMenuPresentation(app: app, scene: scene))
.alert(
AppStrings.Workspace.openTelnetConnectionConfirmation,
isPresented: Binding(
get: { scene.pendingExternalConnection != nil },
set: { if !$0 { scene.pendingExternalConnection = nil } }
)
) {
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { scene.pendingExternalConnection = nil }
Button(AppStrings.Common.continue.swiftUIKey) { scene.confirmExternalConnection() }
} message: {
Text(AppStrings.Workspace.advisoryTelnetPassword)
}
.alert(
scene.isWorkspaceUnavailable
? String(localized: AppStrings.Workspace.workspaceUnavailable)
: String(localized: AppStrings.Common.operationFailed),
isPresented: Binding(
get: { scene.isWorkspaceUnavailable || app.error != nil },
set: {
if !$0 {
scene.isWorkspaceUnavailable = false
app.error = nil
}
})
) {
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) {
scene.isWorkspaceUnavailable = false
app.error = nil
}
} message: {
Text(
scene.isWorkspaceUnavailable
? String(
localized:
AppStrings.Workspace.unavailableWorkspaceExplanation
) : app.error ?? "")
}
.environment(\.mobileLayout, MobileLayout(sizeClass: sizeClass))
}
private var featureTabs: some View {
TabView(selection: selection) {
ForEach(features.registrations) { feature in
Tab(value: feature.id) {
featureNavigation(feature)
.mobileToolbarEnvironment()
} label: {
Label(feature.title, systemImage: feature.symbol)
}
.badge(unreadCount(for: feature.id))
}
}
.tabViewStyle(.sidebarAdaptable)
}
@ViewBuilder
private func featureNavigation(_ feature: MobileFeatureRegistration) -> some View {
if feature.id == .vault {
NavigationStack(path: $scene.vaultNavigationPath) {
feature.makeView(app: app, scene: scene)
.navigationDestination(for: MobileVaultRoute.self) { route in
switch route {
case .history:
MobileConnectionHistoryView(app: app, open: scene.open)
}
}
}
.id(scene.vaultNavigationID)
} else {
NavigationStack {
feature.makeView(app: app, scene: scene)
}
}
}
private var selection: Binding<MobileDestination> {
Binding(
get: { scene.selectedDestination ?? .vault },
set: { scene.selectedDestination = $0 }
)
}
private func unreadCount(for destination: MobileDestination) -> Int {
switch destination {
case .workspaces: scene.workspaceSession.unreadCompletionCount
case .assistant: app.unreadAssistantNotificationCount
default: 0
}
}
}
private struct MobileHistoryMenuPresentation: ViewModifier {
let app: MobileAppModel
@Bindable var scene: MobileSceneModel
func body(content: Content) -> some View {
content
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
app.connectionHistory.refreshMenuDate()
}
.onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in
app.connectionHistory.refreshMenuDate()
}
.operationConfirmation(
.init(AppStrings.Connections.clearConnectionHistoryConfirmation),
isPresented: $scene.confirmsClearConnectionHistory, placement: .centered,
actions: [
.init(AppStrings.Common.clearHistory, role: .destructive) { scene.clearConnectionHistory() },
.init(AppStrings.Common.cancel, role: .cancel) {},
], message: .init(AppStrings.Workspace.clearHistoryWarning)
)
}
}