Files
Termind/TFI/App/MobileSceneModel.swift
T

296 lines
10 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`, while commands and deep
/// links act only on the scene that published this model as its focused value.
@MainActor
@Observable
final class MobileSceneModel {
let id = UUID()
let assistant: MobileAssistantSceneModel
var selectedDestination: MobileDestination? = .vault {
didSet {
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
}
}
var workspaceID: MobileWorkspace.ID?
var isConnectionPickerPresented = false
var isWorkspaceUnavailable = 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 recentWorkspaceIDs: [MobileWorkspace.ID] = []
init(app: MobileAppModel) {
self.app = app
assistant = app.assistant.makeSceneModel()
}
var activeWorkspace: MobileWorkspace? {
guard selectedDestination == .workspaces, let workspaceID else { return nil }
return app.workspaces.first { $0.id == workspaceID }
}
var hasWorkspaces: Bool { !app.workspaces.isEmpty }
var canCloseAllWorkspaces: Bool { !app.savedWorkspaceDescriptors.isEmpty || !app.workspaces.isEmpty }
var canCloseMultipleWorkspaces: Bool { app.closableWorkspaceCount > 1 }
var workspaceCount: Int { app.workspaces.count }
var canCloseWorkspace: Bool { activeWorkspace != nil }
var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh }
func openSettings() {
selectedDestination = .settings
}
func enterReviewPage(_ id: String) {
reviewPageID = id
}
func leaveReviewPage(_ id: String) {
if reviewPageID == id { reviewPageID = nil }
}
func openConnectionPicker() {
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
isConnectionPickerPresented = true
}
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 = app.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
let matchingIDs = Set(
app.workspaces.lazy
.filter { $0.descriptor.hostID == hostID && $0.descriptor.kind == kind }
.map(\.id)
)
let workspaceID =
recentWorkspaceIDs.first(where: matchingIDs.contains)
?? app.workspaces.last(where: {
$0.descriptor.hostID == hostID && $0.descriptor.kind == kind
})?.id
guard let workspaceID else {
open(hostID: hostID, kind: kind)
return
}
selectWorkspace(workspaceID)
}
func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) {
guard let id = app.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 = app.execute(snippet, on: hostIDs).first else { return }
selectWorkspace(first)
}
func selectWorkspace(_ id: MobileWorkspace.ID) {
guard app.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 app.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 app.workspaces.indices.contains(index) else { return }
selectWorkspace(app.workspaces[index].id)
}
func selectNextWorkspace() {
guard !app.workspaces.isEmpty else { return }
guard let activeWorkspace,
let index = app.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
else {
selectWorkspace(at: 0)
return
}
selectWorkspace(at: (index + 1) % app.workspaces.count)
}
func selectPreviousWorkspace() {
guard !app.workspaces.isEmpty else { return }
guard let activeWorkspace,
let index = app.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
else {
selectWorkspace(at: app.workspaces.count - 1)
return
}
selectWorkspace(at: (index - 1 + app.workspaces.count) % app.workspaces.count)
}
func selectLastWorkspace() {
guard !app.workspaces.isEmpty else { return }
selectWorkspace(at: app.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) {
renamingWorkspace = descriptor
}
func closeWorkspace(_ id: MobileWorkspace.ID) {
app.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) {
app.closeOtherWorkspaces(than: id)
recentWorkspaceIDs.removeAll { $0 != id }
if pendingWorkspaceID != id { pendingWorkspaceID = nil }
if workspaceID != id { workspaceID = nil }
}
func closeAllWorkspaces() {
app.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 {
selectWorkspace(app.openExternalConnection(request))
}
} 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 }
selectWorkspace(id)
app.workspaces.first(where: { $0.id == id })?.terminal.requestFocus()
}
func confirmExternalConnection() {
guard let request = pendingExternalConnection else { return }
pendingExternalConnection = nil
selectWorkspace(app.openExternalConnection(request))
}
}