Files
Termind/TFI/App/MobileSceneModel.swift
T

385 lines
14 KiB
Swift

import Foundation
import Observation
import SwiftUI
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// Navigation and presentation state owned by one iOS/iPadOS scene.
///
/// App data remains shared through `MobileAppModel`. Focused commands operate on
/// this window's workspaces; workspace links activate their persistent native owner.
@MainActor
@Observable
final class MobileSceneModel {
let id = UUID()
let workspaceSession: MobileWorkspaceSessionModel
let assistant: MobileAssistantSceneModel
var selectedDestination: MobileDestination? = .vault {
didSet {
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
app.refreshCommandFocus()
}
}
var workspaceID: MobileWorkspace.ID? { didSet { app.refreshCommandFocus() } }
var isConnectionPickerPresented = false
var isBatchStartupPresented = false
var isWorkspaceUnavailable = false
var showsTransfers = false
var pendingExternalConnection: RemoteConnectionURL?
var editingHost: Host?
var renamingWorkspace: MobileWorkspaceDescriptor?
var assistantLibraryRoute: MobileAssistantLibraryRoute?
private(set) var reviewPageID: String?
@ObservationIgnored private let app: MobileAppModel
@ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID?
@ObservationIgnored private var pendingBatchStartup:
(hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol)?
private var recentWorkspaceIDs: [MobileWorkspace.ID] = []
init(app: MobileAppModel, sessionID: String) {
workspaceSession = app.workspaceSession(for: sessionID)
self.app = app
assistant = app.assistant.makeSceneModel()
assistant.paneAccess = app.paneAccess(for: self)
app.registerPaneScene(self)
}
var activeWorkspace: MobileWorkspace? {
guard selectedDestination == .workspaces, let workspaceID else { return nil }
return workspaceSession.workspaces.first { $0.id == workspaceID }
}
var hasWorkspaces: Bool { !workspaceSession.workspaces.isEmpty }
var canCloseAllWorkspaces: Bool {
!workspaceSession.savedWorkspaceDescriptors.isEmpty || !workspaceSession.workspaces.isEmpty
}
var canCloseMultipleWorkspaces: Bool { workspaceSession.closableWorkspaceCount > 1 }
var workspaceCount: Int { workspaceSession.workspaces.count }
var canCloseWorkspace: Bool { activeWorkspace != nil }
var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh }
func openSettings() {
selectedDestination = .settings
}
func openTransfers() {
pendingWorkspaceID = nil
isConnectionPickerPresented = false
isBatchStartupPresented = false
workspaceID = nil
selectedDestination = .workspaces
showsTransfers = true
}
func enterReviewPage(_ id: String) {
reviewPageID = id
}
func leaveReviewPage(_ id: String) {
if reviewPageID == id { reviewPageID = nil }
}
func openConnectionPicker() {
pendingBatchStartup = nil
isBatchStartupPresented = false
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
isConnectionPickerPresented = true
}
func openBatchStartup() {
pendingWorkspaceID = nil
pendingBatchStartup = nil
selectedDestination = .workspaces
workspaceID = nil
isConnectionPickerPresented = false
isBatchStartupPresented = true
}
func prepareBatchStartup(hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol) {
guard isBatchStartupPresented, pendingBatchStartup == nil, !hostIDs.isEmpty else { return }
pendingBatchStartup = (hostIDs, connectionProtocol)
}
func completeBatchStartup() {
isBatchStartupPresented = false
guard let request = pendingBatchStartup else { return }
pendingBatchStartup = nil
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
workspaceSession.batchStartup(hostIDs: request.hostIDs, connectionProtocol: request.connectionProtocol)
}
func isShowingAssistant(_ id: UUID) -> Bool {
selectedDestination == .assistant && assistantLibraryRoute == nil
&& assistant.library.selectedConversationID == id
}
var visibleAssistantConversationID: UUID? {
guard let id = assistant.library.selectedConversationID, isShowingAssistant(id) else { return nil }
return id
}
func openAssistantNotification(_ id: UUID) {
assistantLibraryRoute = nil
selectedDestination = .assistant
if let conversation = assistant.library.conversation(id: id) {
assistant.select(conversation)
} else {
assistantLibraryRoute = .conversations
}
}
func openNewConversation() {
assistant.newConversation()
assistantLibraryRoute = nil
selectedDestination = .assistant
}
func openNewHost() {
selectedDestination = .vault
editingHost = Host(name: "", host: "")
}
func open(hostID: Host.ID, kind: ConnectionHistoryEntry.Kind) {
guard let id = workspaceSession.open(hostID: hostID, kind: kind) else { return }
selectWorkspace(id)
}
@discardableResult
func perform(_ quickAction: MobileHomeScreenQuickAction) -> Bool {
switch quickAction {
case .connect:
openConnectionPicker()
return true
case .history(let hostID):
guard let host = app.library.host(hostID),
let kind = MobileConnectionHistory.preferredKind(for: host)
else {
selectedDestination = .workspaces
workspaceID = nil
isWorkspaceUnavailable = true
return false
}
open(hostID: hostID, kind: kind)
return true
}
}
/// Reuses this scene's most recently selected preferred terminal workspace for the Host.
/// Explicit connection actions continue to call `open(hostID:kind:)` so
/// users can still create independent SSH and SFTP workspaces.
func activateHost(_ hostID: Host.ID) {
guard let host = app.library.host(hostID), let connectionProtocol = host.preferredTerminalProtocol else {
return
}
let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet
guard let workspace = workspaceActivatedByHostRow(for: host) else {
open(hostID: hostID, kind: kind)
return
}
selectWorkspace(workspace.id)
}
/// The connection a Vault Host card will reopen in this window.
func workspaceActivatedByHostRow(for host: Host) -> MobileWorkspace? {
guard let connectionProtocol = host.preferredTerminalProtocol else { return nil }
let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet
let matchingIDs = Set(
workspaceSession.workspaces.lazy
.filter { $0.descriptor.hostID == host.id && $0.descriptor.kind == kind }
.map(\.id)
)
let workspaceID =
recentWorkspaceIDs.first(where: matchingIDs.contains)
?? workspaceSession.workspaces.last(where: {
$0.descriptor.hostID == host.id && $0.descriptor.kind == kind
})?.id
return workspaceID.flatMap { id in workspaceSession.workspaces.first { $0.id == id } }
}
func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) {
guard let id = workspaceSession.open(hostID: launch.hostID, kind: launch.kind, initialPath: launch.path) else {
return
}
selectWorkspace(id)
}
func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) {
guard let first = workspaceSession.execute(snippet, on: hostIDs).first else { return }
selectWorkspace(first)
}
func selectWorkspace(_ id: MobileWorkspace.ID) {
guard workspaceSession.openWorkspace(id) != nil else {
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
isWorkspaceUnavailable = true
return
}
isWorkspaceUnavailable = false
if selectedDestination != .workspaces {
workspaceID = nil
pendingWorkspaceID = id
selectedDestination = .workspaces
return
}
presentWorkspace(id)
}
func activatePendingWorkspace() {
guard selectedDestination == .workspaces, let id = pendingWorkspaceID else { return }
pendingWorkspaceID = nil
presentWorkspace(id)
}
private func presentWorkspace(_ id: MobileWorkspace.ID) {
guard workspaceSession.workspaces.contains(where: { $0.id == id }) else { return }
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = id
recentWorkspaceIDs.removeAll { $0 == id }
recentWorkspaceIDs.insert(id, at: 0)
app.focusWorkspaceActivity(id)
}
func selectWorkspace(at index: Int) {
guard workspaceSession.workspaces.indices.contains(index) else { return }
selectWorkspace(workspaceSession.workspaces[index].id)
}
func selectNextWorkspace() {
guard !workspaceSession.workspaces.isEmpty else { return }
guard let activeWorkspace,
let index = workspaceSession.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
else {
selectWorkspace(at: 0)
return
}
selectWorkspace(at: (index + 1) % workspaceSession.workspaces.count)
}
func selectPreviousWorkspace() {
guard !workspaceSession.workspaces.isEmpty else { return }
guard let activeWorkspace,
let index = workspaceSession.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
else {
selectWorkspace(at: workspaceSession.workspaces.count - 1)
return
}
selectWorkspace(at: (index - 1 + workspaceSession.workspaces.count) % workspaceSession.workspaces.count)
}
func selectLastWorkspace() {
guard !workspaceSession.workspaces.isEmpty else { return }
selectWorkspace(at: workspaceSession.workspaces.count - 1)
}
func generateTerminalCommand() {
guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return }
workspace.generateCommand(using: app.commandGenerator)
}
func presentTerminalFind() {
guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return }
workspace.find.present()
}
func navigateTerminalFind(_ direction: TerminalFindDirection) {
guard let workspace = activeWorkspace, workspace.descriptor.kind == .ssh else { return }
workspace.find.presentAndNavigate(direction)
}
func beginWorkspaceRename(_ descriptor: MobileWorkspaceDescriptor) {
guard workspaceSession.orderedWorkspaceItems.contains(where: { $0.id == descriptor.id }) else { return }
renamingWorkspace = descriptor
}
func closeWorkspace(_ id: MobileWorkspace.ID) {
workspaceSession.close(id)
recentWorkspaceIDs.removeAll { $0 == id }
if pendingWorkspaceID == id { pendingWorkspaceID = nil }
if workspaceID == id { workspaceID = nil }
}
func closeActiveWorkspace() {
guard let activeWorkspace else { return }
closeWorkspace(activeWorkspace.id)
}
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
guard workspaceSession.orderedWorkspaceItems.contains(where: { $0.id == id }) else { return }
workspaceSession.closeOtherWorkspaces(than: id)
recentWorkspaceIDs.removeAll { $0 != id }
if pendingWorkspaceID != id { pendingWorkspaceID = nil }
if workspaceID != id { workspaceID = nil }
}
func closeAllWorkspaces() {
workspaceSession.closeAll()
recentWorkspaceIDs.removeAll()
pendingWorkspaceID = nil
workspaceID = nil
}
func reconcileWorkspaceIDs(_ workspaceIDs: [MobileWorkspace.ID]) {
let existingIDs = Set(workspaceIDs)
recentWorkspaceIDs.removeAll { !existingIDs.contains($0) }
if let pendingWorkspaceID, !workspaceIDs.contains(pendingWorkspaceID) {
self.pendingWorkspaceID = nil
}
guard let workspaceID, !workspaceIDs.contains(workspaceID) else { return }
self.workspaceID = nil
}
func open(_ url: URL) {
if let scheme = url.scheme?.lowercased(), ["ssh", "sftp", "telnet"].contains(scheme) {
do {
let request = try RemoteConnectionURLParser.parse(url)
if request.kind == .telnet, request.advisoryPassword != nil {
pendingExternalConnection = request
} else {
if let id = workspaceSession.openExternalConnection(request) { selectWorkspace(id) }
}
} catch {
app.error = error.localizedDescription
}
return
}
if WorkspaceActivityAttributes.accepts(url) {
isWorkspaceUnavailable = false
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
return
}
guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return }
isWorkspaceUnavailable = false
guard let owner = app.workspaceOwner(id) else {
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
isWorkspaceUnavailable = true
return
}
if owner != workspaceSession.id {
app.activateWorkspaceSession(owner, id) { [weak self] in self?.isWorkspaceUnavailable = true }
return
}
selectWorkspace(id)
workspaceSession.workspaces.first(where: { $0.id == id })?.terminal.requestFocus()
}
func confirmExternalConnection() {
guard let request = pendingExternalConnection else { return }
pendingExternalConnection = nil
if let id = workspaceSession.openExternalConnection(request) { selectWorkspace(id) }
}
}