diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7540e745..8bd92149 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -2996,6 +2996,16 @@ } } }, + "Close Find" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭查找" + } + } + } + }, "Close model details" : { "localizations" : { "zh-Hans" : { @@ -3394,6 +3404,18 @@ } } }, + "Connect remote MCP tools" : { + "comment" : "A feature of Termind Pro on iOS.", + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接远程MCP工具" + } + } + } + }, "Connect to remote MCP servers over HTTP. New tools always ask for approval by default." : { "comment" : "A description of MCP servers.", "isCommentAutoGenerated" : true, @@ -9739,6 +9761,18 @@ } } }, + "One purchase for AI, more monitored servers, remote MCP tools, and iCloud sync." : { + "comment" : "A summary of Termind Pro features available on iOS.", + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一次买断,解锁AI、更多服务器监控、远程MCP工具与iCloud同步。" + } + } + } + }, "One purchase unlocks multi-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and iCloud sync for your Vault, Metrics, and settings across devices." : { "localizations" : { "zh-Hans" : { @@ -17626,4 +17660,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index b22c8f2b..f510b01f 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -172,43 +172,80 @@ public struct SSHAuthenticationForm: View { .frame(maxWidth: .infinity) .textFieldStyle(.roundedBorder) #if os(iOS) - .multilineTextAlignment(.trailing) + .multilineTextAlignment(.leading) .textInputAutocapitalization(.never) .autocorrectionDisabled() #endif - HStack { - Button(action: cancel) { ConnectionActionLabel(title: "Cancel", symbol: "xmark") }.keyboardShortcut( - .cancelAction) - Spacer() - if needsPassphrase { - Button { - commit(save: false) - } label: { - ConnectionActionLabel(title: "Continue", symbol: "arrow.right") + actions + } + } + + @ViewBuilder + private var actions: some View { + if needsPassphrase { + ViewThatFits(in: .horizontal) { + HStack { + cancelButton + Spacer() + passphraseContinueButton + } + VStack(spacing: 10) { + cancelButton.frame(maxWidth: .infinity, alignment: .leading) + passphraseContinueButton.frame(maxWidth: .infinity, alignment: .trailing) + } + } + } else { + ViewThatFits(in: .horizontal) { + HStack { + cancelButton + Spacer() + continueButton + saveAndContinueButton + } + VStack(spacing: 10) { + HStack { + cancelButton + Spacer() + continueButton } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!canContinue) - } else { - Button { - commit(save: false) - } label: { - ConnectionActionLabel(title: "Continue", symbol: "arrow.right") - }.disabled(!canContinue) - Button { - commit(save: true) - } label: { - ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark") - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(!canContinue) + saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) } } } } + private var cancelButton: some View { + Button(action: cancel) { ConnectionActionLabel(title: "Cancel", symbol: "xmark") } + .keyboardShortcut(.cancelAction) + } + + private var continueButton: some View { + Button { + commit(save: false) + } label: { + ConnectionActionLabel(title: "Continue", symbol: "arrow.right") + } + .disabled(!canContinue) + } + + private var passphraseContinueButton: some View { + continueButton + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + + private var saveAndContinueButton: some View { + Button { + commit(save: true) + } label: { + ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canContinue) + } + private func updateFocusForRequirement() { if needsPassphrase { focusedField = .passphrase @@ -397,24 +434,73 @@ public struct SSHHostKeyConfirmationForm: View { .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(.background.opacity(0.65), in: .rect(cornerRadius: 8)) - HStack { - Button(role: .destructive, action: deny) { ConnectionActionLabel(title: "Deny", symbol: "xmark") } - .keyboardShortcut(.cancelAction) - Spacer() - if let trust { - Button(action: proceed) { ConnectionActionLabel(title: "Continue", symbol: "arrow.right") } - .help("Continue once without saving this host key") - Button(action: trust) { - ConnectionActionLabel(title: "Trust and Continue", symbol: "checkmark.shield") + actions + } + } + + @ViewBuilder + private var actions: some View { + if trust != nil { + ViewThatFits(in: .horizontal) { + HStack { + denyButton + Spacer() + continueButton + trustButton + } + VStack(spacing: 10) { + HStack { + denyButton + Spacer() + continueButton } - .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) - } else { - Button(action: proceed) { ConnectionActionLabel(title: "Continue", symbol: "arrow.right") } - .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + trustButton.frame(maxWidth: .infinity, alignment: .trailing) + } + } + } else { + ViewThatFits(in: .horizontal) { + HStack { + denyButton + Spacer() + continueButton + } + VStack(spacing: 10) { + denyButton.frame(maxWidth: .infinity, alignment: .leading) + continueButton.frame(maxWidth: .infinity, alignment: .trailing) } } } } + + private var denyButton: some View { + Button(role: .destructive, action: deny) { ConnectionActionLabel(title: "Deny", symbol: "xmark") } + .keyboardShortcut(.cancelAction) + } + + @ViewBuilder + private var continueButton: some View { + if trust == nil { + baseContinueButton + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } else { + baseContinueButton + .help("Continue once without saving this host key") + } + } + + private var baseContinueButton: some View { + Button(action: proceed) { ConnectionActionLabel(title: "Continue", symbol: "arrow.right") } + } + + private var trustButton: some View { + Button(action: { trust?() }) { + ConnectionActionLabel(title: "Trust and Continue", symbol: "checkmark.shield") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + private var explanation: String { switch prompt.kind { case .firstUse: @@ -437,7 +523,9 @@ private struct ConnectionActionLabel: View { var body: some View { #if os(iOS) - Label(title, systemImage: symbol).fixedSize(horizontal: false, vertical: true) + Label(title, systemImage: symbol) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .frame(minHeight: 44) #else Text(title) diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 0891da96..3f4fad8c 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -49,6 +49,8 @@ public struct ConnectionJourneyHeader: View { .accessibilityLabel(journey.showsLogs ? Text("Hide Logs") : Text("Show Logs")) } else { Text(journey.showsLogs ? "Hide Logs" : "Show Logs") + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } .buttonStyle(.bordered) @@ -165,9 +167,15 @@ public struct ConnectionJourneyReconnectStatus: View { .lineLimit(2) .multilineTextAlignment(.center) .textSelection(.enabled) - HStack(spacing: 10) { - Button("Retry Now", action: retryNow).buttonStyle(.borderedProminent) - Button("Stop Reconnecting", role: .cancel, action: stop).buttonStyle(.bordered) + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + retryButton + stopButton + } + VStack(spacing: 10) { + retryButton + stopButton + } } .controlSize(.large) } @@ -175,6 +183,20 @@ public struct ConnectionJourneyReconnectStatus: View { } } + private var retryButton: some View { + Button("Retry Now", action: retryNow) + .buttonStyle(.borderedProminent) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + private var stopButton: some View { + Button("Stop Reconnecting", role: .cancel, action: stop) + .buttonStyle(.bordered) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + private func statusText(at date: Date) -> String { let seconds = max(0, Int(ceil(state.nextRetry.timeIntervalSince(date)))) if seconds == 0 { return String(localized: "Reconnect attempt \(state.attempt)…") } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 5ff3e366..527d38a2 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -328,6 +328,15 @@ tests/discovery use ephemeral snapshots, cancel on editor/config changes, and ca repopulate discarded drafts. Secrets remain in memory until atomic save and roll back on metadata failure. Mobile does not start the local MCP listener. +Each mobile scene owns one navigation/command model and publishes it as a focused +scene value, so hardware-keyboard commands never operate another iPad window. Mobile +uses the macOS shortcuts for capabilities that exist on both platforms: navigation, +connection/creation, terminal Find and Command Generator, Workspace close/switch, +and Assistant send/stop. Workspace switching activates lazy restoration but never +claims terminal focus. The UIKit terminal surface owns F3/Shift-F3 and replaces +Ghostty's bare/Shift-Escape commands only while Find is presented; otherwise Escape +continues to the terminal unchanged. + Mobile Agent runs belong to the app, not the visible page. Conversations retain independent drafts/runs; archiving persists metadata before navigation and becomes read-only until restored. Attachment parsing and speech are injected services; speech @@ -342,6 +351,14 @@ uses one UIKit bridge for transcript and large SFTP lists. Bounded navigation an archive pages remain SwiftUI `List`. Shared frameworks never import ListViewKit, and architecture tests own its allowlist. +Mobile workspace file drops use one upload policy for native routing and payload +loading. The visible workspace marks its bounds for a window drop interaction; +the terminal adapter forwards file drops to that destination instead of Ghostty's +local-path paste, while text and web links keep Ghostty's behavior. Leaving the +destination restores the original interaction and clears targeting. File-provider +representations are copied within their callbacks into app-owned staging before +upload preparation; queued transfers retain those files after navigation away. + ### Assistant transcript presentation parity (2026-09-12) diff --git a/TFI/App/MobileCommands.swift b/TFI/App/MobileCommands.swift new file mode 100644 index 00000000..c094c695 --- /dev/null +++ b/TFI/App/MobileCommands.swift @@ -0,0 +1,105 @@ +import SwiftUI +import TermindDomain + +private struct MobileSceneFocusedValueKey: FocusedValueKey { + typealias Value = MobileSceneModel +} + +extension FocusedValues { + var mobileScene: MobileSceneModel? { + get { self[MobileSceneFocusedValueKey.self] } + set { self[MobileSceneFocusedValueKey.self] = newValue } + } +} + +nonisolated enum MobileShortcut { + static let settings = KeyboardShortcut(",", modifiers: .command) + static let connect = KeyboardShortcut("t", modifiers: .command) + static let newChat = KeyboardShortcut("t", modifiers: [.command, .option]) + static let newHost = KeyboardShortcut("n", modifiers: [.command, .shift]) + static let generateCommand = KeyboardShortcut("i", modifiers: .command) + static let find = KeyboardShortcut("f", modifiers: .command) + static let findNext = KeyboardShortcut("g", modifiers: .command) + static let findPrevious = KeyboardShortcut("g", modifiers: [.command, .shift]) + static let closeWorkspace = KeyboardShortcut("w", modifiers: .command) + static let nextWorkspace = KeyboardShortcut("]", modifiers: [.command, .shift]) + static let previousWorkspace = KeyboardShortcut("[", modifiers: [.command, .shift]) + static let send = KeyboardShortcut(.return, modifiers: .command) + static let stop = KeyboardShortcut(".", modifiers: .command) + + static func workspace(_ index: Int) -> KeyboardShortcut { + KeyboardShortcut(KeyEquivalent(Character("\(index)")), modifiers: .command) + } +} + +/// Scene commands mirror the macOS key map while targeting only the frontmost +/// iOS/iPadOS scene through `FocusedValues.mobileScene`. +struct MobileCommands: Commands { + @FocusedValue(\.mobileScene) private var scene + + var body: some Commands { + CommandGroup(replacing: .appSettings) { + Button("Settings", systemImage: "gearshape") { scene?.openSettings() } + .keyboardShortcut(MobileShortcut.settings) + .disabled(scene == nil) + } + + CommandGroup(replacing: .newItem) { + Button("Connect", systemImage: "network") { scene?.openConnectionPicker() } + .keyboardShortcut(MobileShortcut.connect) + .disabled(scene == nil) + Button("New Chat", systemImage: "plus.bubble") { scene?.openNewConversation() } + .keyboardShortcut(MobileShortcut.newChat) + .disabled(scene == nil) + Button("New Host", systemImage: "server.rack") { scene?.openNewHost() } + .keyboardShortcut(MobileShortcut.newHost) + .disabled(scene == nil) + } + + CommandGroup(after: .pasteboard) { + Button("Generate Command", systemImage: "sparkles") { scene?.generateTerminalCommand() } + .keyboardShortcut(MobileShortcut.generateCommand) + .disabled(scene?.canUseTerminalCommands != true) + + Divider() + + Button("Find", systemImage: "magnifyingglass") { scene?.presentTerminalFind() } + .keyboardShortcut(MobileShortcut.find) + .disabled(scene?.canUseTerminalCommands != true) + Button("Find Next in Terminal") { scene?.navigateTerminalFind(.previous) } + .keyboardShortcut(MobileShortcut.findNext) + .disabled(scene?.canUseTerminalCommands != true) + Button("Find Previous in Terminal") { scene?.navigateTerminalFind(.next) } + .keyboardShortcut(MobileShortcut.findPrevious) + .disabled(scene?.canUseTerminalCommands != true) + } + + CommandGroup(replacing: .saveItem) { + Button("Close Workspace", systemImage: "xmark") { scene?.closeActiveWorkspace() } + .keyboardShortcut(MobileShortcut.closeWorkspace) + .disabled(scene?.canCloseWorkspace != true) + } + + CommandMenu("Workspaces") { + Button("Next Workspace", systemImage: "chevron.forward") { scene?.selectNextWorkspace() } + .keyboardShortcut(MobileShortcut.nextWorkspace) + .disabled(scene?.hasWorkspaces != true) + Button("Previous Workspace", systemImage: "chevron.backward") { scene?.selectPreviousWorkspace() } + .keyboardShortcut(MobileShortcut.previousWorkspace) + .disabled(scene?.hasWorkspaces != true) + + Divider() + + ForEach(1...8, id: \.self) { index in + Button("Workspace \(index)", systemImage: "terminal") { + scene?.selectWorkspace(at: index - 1) + } + .keyboardShortcut(MobileShortcut.workspace(index)) + .disabled((scene?.workspaceCount ?? 0) < index) + } + Button("Last Workspace", systemImage: "terminal") { scene?.selectLastWorkspace() } + .keyboardShortcut(MobileShortcut.workspace(9)) + .disabled(scene?.hasWorkspaces != true) + } + } +} diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 25fcf775..3d84fd42 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -1,26 +1,22 @@ import SwiftUI -import TermindDomain import UIKit -import struct TermindDomain.Host - /// iPhone has four destinations. iPad retains the sidebar with individual library routes. struct MobileRootView: View { let app: MobileAppModel @Environment(\.scenePhase) private var scenePhase @Environment(\.mobileInterfaceIdiom) private var interfaceIdiom - @State private var sceneID = UUID() - @State private var tab: MobileTab = .vault - @State private var selection: MobileDestination? = .vault - @State private var workspaceID: UUID? - @State private var selectingConnection = false - @State private var unavailable = false - @State private var renamingWorkspace: MobileWorkspace? + @State private var scene: MobileSceneModel + + init(app: MobileAppModel) { + self.app = app + _scene = State(initialValue: MobileSceneModel(app: app)) + } var body: some View { Group { if interfaceIdiom != .pad { - TabView(selection: $tab) { + TabView(selection: $scene.selectedTab) { NavigationStack { vault } .tabItem { Label("Vault", systemImage: "server.rack") }.tag(MobileTab.vault) NavigationStack { workspaceList } @@ -32,7 +28,7 @@ struct MobileRootView: View { } } else { NavigationSplitView { - List(selection: $selection) { + List(selection: $scene.selectedDestination) { ForEach(MobileDestination.allCases) { destination in NavigationLink(value: destination) { Label(destination.title, systemImage: destination.symbol) @@ -41,7 +37,7 @@ struct MobileRootView: View { } } detail: { NavigationStack { - switch selection { + switch scene.selectedDestination { case .vault: sidebarVault case .assistant: assistant case .settings: settings @@ -56,68 +52,54 @@ struct MobileRootView: View { .sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) { MobileProView(pro: app.pro) } - .sheet(item: $renamingWorkspace) { workspace in + .sheet(item: $scene.renamingWorkspace) { workspace in MobileWorkspaceRenameView(workspace: workspace) { title in app.renameWorkspace(workspace.id, to: title) } } - .onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(sceneID, phase: phase) } + .onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(scene.id, phase: phase) } .onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() } .onChange(of: app.workspaces.map(\.id)) { _, workspaceIDs in - guard let workspaceID, !workspaceIDs.contains(workspaceID) else { return } - self.workspaceID = nil - } - .onDisappear { app.removeScene(sceneID) } - .onOpenURL { url in - if WorkspaceActivityAttributes.accepts(url) { - tab = .workspaces - selection = .workspaces - workspaceID = nil - return - } - guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return } - tab = .workspaces - selection = .workspaces - if let workspace = app.workspaces.first(where: { $0.id == id }) { - workspace.activateWhenOpened() - workspaceID = id - app.focusWorkspaceActivity(id) - workspace.terminal.requestFocus() - } else { - unavailable = true - } + scene.reconcileWorkspaceIDs(workspaceIDs) } + .onDisappear { app.removeScene(scene.id) } + .onOpenURL(perform: scene.open) + .focusedSceneValue(\.mobileScene, scene) .alert( - unavailable ? String(localized: "Workspace Unavailable") : String(localized: "Operation Failed"), + scene.isWorkspaceUnavailable + ? String(localized: "Workspace Unavailable") : String(localized: "Operation Failed"), isPresented: Binding( - get: { unavailable || app.error != nil }, + get: { scene.isWorkspaceUnavailable || app.error != nil }, set: { if !$0 { - unavailable = false + scene.isWorkspaceUnavailable = false app.error = nil } }) ) { Button("OK", role: .cancel) { - unavailable = false + scene.isWorkspaceUnavailable = false app.error = nil } } message: { Text( - unavailable + scene.isWorkspaceUnavailable ? String(localized: "This workspace was closed or its host was removed.") : app.error ?? "") } } private var vault: some View { - MobileVaultView(app: app, open: open, executeSnippet: executeSnippet) + MobileVaultView( + app: app, editingHost: $scene.editingHost, + open: scene.open, executeSnippet: scene.execute) } private var sidebarVault: some View { MobileVaultView( - app: app, open: open, showsMetrics: false, - executeSnippet: executeSnippet) + app: app, editingHost: $scene.editingHost, + open: scene.open, showsMetrics: false, + executeSnippet: scene.execute) } private var workspaceList: some View { @@ -135,31 +117,31 @@ struct MobileRootView: View { .toolbar { MobileWorkspaceToolbar( hasWorkspaces: !app.workspaces.isEmpty, - closeAll: closeAllWorkspaces, - connect: { selectingConnection = true }) + closeAll: scene.closeAllWorkspaces, + connect: scene.openConnectionPicker) } - .navigationDestination(item: $workspaceID) { id in + .navigationDestination(item: $scene.workspaceID) { id in if let workspace = app.workspaces.first(where: { $0.id == id }) { MobileWorkspaceView( library: app.library, pro: app.pro, commandGenerator: app.commandGenerator, workspace: workspace, workspaces: app.workspaces, - selectWorkspace: selectWorkspace, - renameWorkspace: beginWorkspaceRename, - openSameDirectory: openSameDirectory, - closeWorkspace: closeWorkspace + selectWorkspace: scene.selectWorkspace, + renameWorkspace: scene.beginWorkspaceRename, + openSameDirectory: scene.openSameDirectory, + closeWorkspace: scene.closeWorkspace ) .onAppear { workspace.activateWhenOpened() } } } - .sheet(isPresented: $selectingConnection) { + .sheet(isPresented: $scene.isConnectionPickerPresented) { NavigationStack { MobileConnectionPicker( library: app.library, connect: { hostID, kind in - selectingConnection = false - open(hostID, kind) + scene.isConnectionPickerPresented = false + scene.open(hostID: hostID, kind: kind) }, - cancel: { selectingConnection = false }) + cancel: { scene.isConnectionPickerPresented = false }) } .mobileSheetPresentation(compact: true) } @@ -176,7 +158,7 @@ struct MobileRootView: View { } ForEach(app.workspaces) { workspace in Button { - selectWorkspace(workspace.id) + scene.selectWorkspace(workspace.id) } label: { HStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { @@ -199,12 +181,12 @@ struct MobileRootView: View { } .buttonStyle(.plain) .swipeActions { - Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) } + Button("Close", systemImage: "xmark", role: .destructive) { scene.closeWorkspace(workspace.id) } } .contextMenu { - Button("Rename", systemImage: "pencil") { beginWorkspaceRename(workspace) } + Button("Rename", systemImage: "pencil") { scene.beginWorkspaceRename(workspace) } Divider() - Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) } + Button("Close", systemImage: "xmark", role: .destructive) { scene.closeWorkspace(workspace.id) } } } } @@ -216,7 +198,9 @@ struct MobileRootView: View { } private var assistant: some View { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView( + app: app, model: app.assistant, + libraryRoute: $scene.assistantLibraryRoute) } private var metrics: some View { @@ -227,46 +211,4 @@ struct MobileRootView: View { MobileSettingsView(app: app) } - private func open(_ hostID: UUID, _ kind: MobileWorkspaceDescriptor.Kind) { - workspaceID = app.open(hostID: hostID, kind: kind) - tab = .workspaces - selection = .workspaces - } - - private func executeSnippet(_ snippet: Snippet, on hostIDs: [Host.ID]) { - guard let first = app.execute(snippet, on: hostIDs).first else { return } - workspaceID = first - tab = .workspaces - selection = .workspaces - app.focusWorkspaceActivity(first) - } - - private func selectWorkspace(_ id: UUID) { - guard let workspace = app.workspaces.first(where: { $0.id == id }) else { return } - workspace.activateWhenOpened() - workspaceID = id - app.focusWorkspaceActivity(id) - } - - private func beginWorkspaceRename(_ workspace: MobileWorkspace) { - renamingWorkspace = workspace - } - - private func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) { - guard let id = app.open(hostID: launch.hostID, kind: launch.kind, initialPath: launch.path) else { return } - workspaceID = id - tab = .workspaces - selection = .workspaces - app.focusWorkspaceActivity(id) - } - - private func closeWorkspace(_ id: UUID) { - app.close(id) - if workspaceID == id { workspaceID = nil } - } - - private func closeAllWorkspaces() { - app.closeAll() - workspaceID = nil - } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift new file mode 100644 index 00000000..a868c607 --- /dev/null +++ b/TFI/App/MobileSceneModel.swift @@ -0,0 +1,187 @@ +import Foundation +import Observation +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() + var selectedDestination: MobileDestination? = .vault + var workspaceID: MobileWorkspace.ID? + var isConnectionPickerPresented = false + var isWorkspaceUnavailable = false + var editingHost: Host? + var renamingWorkspace: MobileWorkspace? + var assistantLibraryRoute: MobileAssistantLibraryRoute? + + @ObservationIgnored private let app: MobileAppModel + + init(app: MobileAppModel) { + self.app = app + } + + var selectedTab: MobileTab { + get { + switch selectedDestination { + case .workspaces: .workspaces + case .assistant: .assistant + case .settings: .settings + case .vault, .metrics, nil: .vault + } + } + set { + switch newValue { + case .vault: selectedDestination = .vault + case .workspaces: selectedDestination = .workspaces + case .assistant: selectedDestination = .assistant + case .settings: selectedDestination = .settings + } + } + } + + 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 workspaceCount: Int { app.workspaces.count } + var canCloseWorkspace: Bool { activeWorkspace != nil } + var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh } + + func openSettings() { + selectedDestination = .settings + } + + func openConnectionPicker() { + selectedDestination = .workspaces + workspaceID = nil + isConnectionPickerPresented = true + } + + func openNewConversation() { + app.assistant.newConversation() + assistantLibraryRoute = nil + selectedDestination = .assistant + } + + func openNewHost() { + selectedDestination = .vault + editingHost = Host(name: "", host: "") + } + + func open(hostID: Host.ID, kind: MobileWorkspaceDescriptor.Kind) { + guard let id = app.open(hostID: hostID, kind: kind) else { return } + selectWorkspace(id) + } + + 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 let workspace = app.workspaces.first(where: { $0.id == id }) else { return } + workspace.activateWhenOpened() + selectedDestination = .workspaces + workspaceID = id + 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(_ workspace: MobileWorkspace) { + renamingWorkspace = workspace + } + + func closeWorkspace(_ id: MobileWorkspace.ID) { + app.close(id) + if workspaceID == id { workspaceID = nil } + } + + func closeActiveWorkspace() { + guard let activeWorkspace else { return } + closeWorkspace(activeWorkspace.id) + } + + func closeAllWorkspaces() { + app.closeAll() + workspaceID = nil + } + + func reconcileWorkspaceIDs(_ workspaceIDs: [MobileWorkspace.ID]) { + guard let workspaceID, !workspaceIDs.contains(workspaceID) else { return } + self.workspaceID = nil + } + + func open(_ url: URL) { + if WorkspaceActivityAttributes.accepts(url) { + selectedDestination = .workspaces + workspaceID = nil + return + } + guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return } + guard let workspace = app.workspaces.first(where: { $0.id == id }) else { + isWorkspaceUnavailable = true + return + } + selectWorkspace(id) + workspace.terminal.requestFocus() + } +} diff --git a/TFI/App/TermindIOSApp.swift b/TFI/App/TermindIOSApp.swift index 8bcb8bce..22eb5257 100644 --- a/TFI/App/TermindIOSApp.swift +++ b/TFI/App/TermindIOSApp.swift @@ -31,5 +31,6 @@ struct TermindIOSApp: App { description: Text("The local data store could not be opened. Please restart the app.")) } } + .commands { MobileCommands() } } } diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index 625e6e0c..02f23a1e 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -119,6 +119,21 @@ struct MobileAssistantComposer: View { } .onAppear(perform: normalizeReasoningEffort) .onChange(of: reasoningResolution) { _, _ in normalizeReasoningEffort() } + .background { keyboardShortcuts } + } + + private var keyboardShortcuts: some View { + Group { + Button("Send", action: submitFromKeyboard) + .keyboardShortcut(MobileShortcut.send) + .disabled(!canSend) + Button("Stop", action: conversation.stop) + .keyboardShortcut(MobileShortcut.stop) + .disabled(!conversation.isStreaming) + } + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) } private var composerSurface: some View { diff --git a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift index 8e42d64c..2dea8b97 100644 --- a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift +++ b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift @@ -104,6 +104,7 @@ struct MobileAssistantActivity: View { } .foregroundStyle(.secondary) .modifier(MobileAssistantScanning(active: true)) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index cc9bca45..9d99aabc 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -20,7 +20,7 @@ enum MobileAssistantLibraryRoute: String, Identifiable, Hashable { struct MobileAssistantView: View { let app: MobileAppModel @Bindable var model: MobileAssistantModel - @State private var libraryRoute: MobileAssistantLibraryRoute? + @Binding var libraryRoute: MobileAssistantLibraryRoute? @State private var restoreComposerFocusAfterLibrary = false @State private var nextComposerFocusRequest = 0 @State private var composerFocusRequest: Int? @@ -29,6 +29,16 @@ struct MobileAssistantView: View { @State private var deleting: ConversationModel? @FocusState private var composerFocused: Bool + init( + app: MobileAppModel, + model: MobileAssistantModel, + libraryRoute: Binding + ) { + self.app = app + self.model = model + _libraryRoute = libraryRoute + } + var body: some View { detail .navigationTitle( diff --git a/TFI/Features/Commerce/MobileProView.swift b/TFI/Features/Commerce/MobileProView.swift index ea431365..a225b7cc 100644 --- a/TFI/Features/Commerce/MobileProView.swift +++ b/TFI/Features/Commerce/MobileProView.swift @@ -37,7 +37,7 @@ struct MobileProView: View { .foregroundStyle(.tint) .symbolRenderingMode(.hierarchical) Text("Termind Pro").font(.largeTitle.bold()) - Text("One purchase for advanced workspaces, AI, MCP, and iCloud sync.") + Text("One purchase for AI, more monitored servers, remote MCP tools, and iCloud sync.") .font(.body).foregroundStyle(.secondary).multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } @@ -78,7 +78,7 @@ struct MobileProView: View { Divider().padding(.leading, 52) feature("Upload files by dropping them on a terminal", symbol: "arrow.up.doc") Divider().padding(.leading, 52) - feature("Connect MCP tools and run the built-in MCP Server", symbol: "server.rack") + feature("Connect remote MCP tools", symbol: "server.rack") Divider().padding(.leading, 52) feature("Sync Vault, Metrics, and Settings across devices", symbol: "icloud") } diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index edb9e00e..42a93c05 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -130,19 +130,21 @@ struct MobileVaultView: View { let open: (UUID, MobileWorkspaceDescriptor.Kind) -> Void let showsMetrics: Bool let executeSnippet: (Snippet, [Host.ID]) -> Void + @Binding var editingHost: Host? @State private var query = "" - @State private var editingHost: Host? @State private var deletingHost: Host? @State private var groupName = "" @State private var addingGroup = false init( app: MobileAppModel, + editingHost: Binding, open: @escaping (UUID, MobileWorkspaceDescriptor.Kind) -> Void, showsMetrics: Bool = true, executeSnippet: @escaping (Snippet, [Host.ID]) -> Void = { _, _ in } ) { self.app = app + _editingHost = editingHost self.open = open self.showsMetrics = showsMetrics self.executeSnippet = executeSnippet diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 40eeb0dd..75d202d4 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -7,6 +7,7 @@ import TermindDomain import TermindInfrastructure import TermindPresentation import TermindRemoteInfrastructure +import UIKit import struct TermindDomain.Host @@ -115,6 +116,7 @@ final class MobileWorkspace: Identifiable { private let commandDraft = TerminalCommandDraftController() private var workingDirectoryObservation: AnyCancellable? private let surfaceOwner = MobileTerminalSurface() + var uploadTerminalView: UIView? { descriptor.kind == .ssh ? surfaceOwner.view : nil } let terminal: TerminalViewState private let backend: InMemoryTerminalSession private var ssh: SSHSessionActor? @@ -248,6 +250,7 @@ final class MobileWorkspace: Identifiable { relay?.owner?.receiveWorkingDirectory(path) } } + surfaceOwner.bindFind(find) } func dismissTerminalTextSelection() { diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index a04ffdbe..0fa0afed 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -37,6 +37,9 @@ struct MobileWorkspaceView: View { @State private var showingTransfers = false @State private var uploadConflictPresented = false @State private var uploadDropTargeted = false + @State private var workspaceIsVisible = false + @State private var uploadDropTask: Task? + @Environment(\.scenePhase) private var scenePhase @State private var downloadTarget: RemoteFile? @State private var previewURL: URL? @@ -47,12 +50,26 @@ struct MobileWorkspaceView: View { DropTargetOverlay(presentation: uploadDropPresentation) } } - .contentShape(.rect) - .onDrop( - of: [.fileURL], - isTargeted: $uploadDropTargeted, - perform: receiveUploadProviders - ) + .background { + MobileWindowDropTarget( + isTargeted: $uploadDropTargeted, + isEnabled: workspaceIsVisible && scenePhase == .active, + terminalView: workspace.uploadTerminalView, + receive: receiveUploadProviders + ) + .id(workspace.id) + } + .onAppear { workspaceIsVisible = true } + .onDisappear { + workspaceIsVisible = false + uploadDropTargeted = false + cancelDropLoading() + } + .onChange(of: workspace.id) { _, _ in + uploadDropTargeted = false + cancelDropLoading() + } + .onChange(of: workspace.uploadDestination) { _, _ in cancelDropLoading() } } private var workspaceChrome: some View { @@ -95,19 +112,16 @@ struct MobileWorkspaceView: View { Button("Generate Command", systemImage: "sparkles") { workspace.generateCommand(using: commandGenerator) } - .keyboardShortcut("i", modifiers: .command) Divider() Button("Snippets", systemImage: "curlybraces") { terminalSheet = .snippets } Button("History", systemImage: "clock") { terminalSheet = .commandHistory } Divider() Button("Next Match", systemImage: "chevron.down") { - workspace.find.presentAndNavigate(.next) - } - .keyboardShortcut("g", modifiers: .command) - Button("Previous Match", systemImage: "chevron.up") { workspace.find.presentAndNavigate(.previous) } - .keyboardShortcut("g", modifiers: [.command, .shift]) + Button("Previous Match", systemImage: "chevron.up") { + workspace.find.presentAndNavigate(.next) + } Divider() sameDirectoryMenuAction Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) } @@ -391,26 +405,37 @@ struct MobileWorkspaceView: View { } private func receiveUploadProviders(_ providers: [NSItemProvider]) -> Bool { - let kinds: Set = [.files] - guard MobileDropPayloadLoader.accepts(providers, kinds: kinds) else { return false } - Task { + guard MobileDropPayloadLoader.acceptsUpload(providers) else { return false } + uploadDropTask?.cancel() + let destination = workspace.uploadDestination + uploadDropTask = Task { let payload = await MobileDropPayloadLoader.load( providers, - kinds: kinds, + kinds: [.files], fileMode: .staged ) + guard !Task.isCancelled, workspace.uploadDestination == destination else { + if let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) } + return + } if !payload.fileURLs.isEmpty { let accepted = receiveUpload(payload.fileURLs) if !accepted, let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) } - } else if let error = payload.failures.first { + } + if let error = payload.failures.first { workspace.operationError = error } } return true } + private func cancelDropLoading() { + uploadDropTask?.cancel() + uploadDropTask = nil + } + private func enqueuePendingUpload(_ policy: TransferConflictPolicy) { workspace.enqueuePendingTransfer(policy) uploadConflictPresented = false diff --git a/TFI/Platform/MobileTerminalDropTarget.swift b/TFI/Platform/MobileTerminalDropTarget.swift new file mode 100644 index 00000000..97fd10ab --- /dev/null +++ b/TFI/Platform/MobileTerminalDropTarget.swift @@ -0,0 +1,69 @@ +import UIKit + +/// Routes file drops to the workspace while retaining Ghostty's text/URL drop behavior. +/// The original interaction is restored when the workspace destination is detached. +@MainActor +final class MobileTerminalDropTarget: NSObject, UIDropInteractionDelegate { + private weak var view: UIView? + private weak var uploadDelegate: (any UIDropInteractionDelegate)? + private let original: UIDropInteraction + private lazy var interaction = UIDropInteraction(delegate: self) + + init?(view: UIView, uploadDelegate: any UIDropInteractionDelegate) { + // Navigation can reattach a retained terminal before the old SwiftUI + // destination is dismantled. Never wrap another workspace's forwarder. + for interaction in view.interactions.compactMap({ $0 as? UIDropInteraction }) { + (interaction.delegate as? MobileTerminalDropTarget)?.uninstall() + } + guard let original = view.interactions.compactMap({ $0 as? UIDropInteraction }).first else { return nil } + self.view = view + self.uploadDelegate = uploadDelegate + self.original = original + super.init() + view.removeInteraction(original) + view.addInteraction(interaction) + } + + func uninstall() { + guard let view, interaction.view === view else { return } + view.removeInteraction(interaction) + view.addInteraction(original) + self.view = nil + uploadDelegate = nil + } + + private func delegate(for session: any UIDropSession) -> (any UIDropInteractionDelegate)? { + MobileDropPayloadLoader.acceptsUpload(session.items.map(\.itemProvider)) ? uploadDelegate : original.delegate + } + + func dropInteraction(_ interaction: UIDropInteraction, canHandle session: any UIDropSession) -> Bool { + delegate(for: session)?.dropInteraction?(interaction, canHandle: session) ?? false + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: any UIDropSession) { + delegate(for: session)?.dropInteraction?(interaction, sessionDidEnter: session) + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession) + -> UIDropProposal + { + delegate(for: session)?.dropInteraction?(interaction, sessionDidUpdate: session) + ?? UIDropProposal(operation: .cancel) + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: any UIDropSession) { + delegate(for: session)?.dropInteraction?(interaction, sessionDidExit: session) + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: any UIDropSession) { + delegate(for: session)?.dropInteraction?(interaction, sessionDidEnd: session) + } + + func dropInteraction(_ interaction: UIDropInteraction, performDrop session: any UIDropSession) { + delegate(for: session)?.dropInteraction?(interaction, performDrop: session) + } + + func dropInteraction(_ interaction: UIDropInteraction, concludeDrop session: any UIDropSession) { + delegate(for: session)?.dropInteraction?(interaction, concludeDrop: session) + } +} diff --git a/TFI/Platform/MobileTerminalSurface.swift b/TFI/Platform/MobileTerminalSurface.swift index cd7f7094..42365f1c 100644 --- a/TFI/Platform/MobileTerminalSurface.swift +++ b/TFI/Platform/MobileTerminalSurface.swift @@ -1,8 +1,69 @@ import GhosttyTerminal +import TermindApplication +import TermindDomain import UIKit /// A workspace owns its native surface even while SwiftUI navigates away from it. @MainActor final class MobileTerminalSurface { - let view = TerminalView(frame: .zero) + let view = MobileTerminalView(frame: .zero) + + func bindFind(_ find: TerminalFindController) { + view.findController = find + } +} + +/// Adds app-level terminal-find equivalents without taking ordinary terminal +/// input away from Ghostty. Escape is replaced only while Find is presented. +@MainActor +final class MobileTerminalView: TerminalView { + weak var findController: TerminalFindController? + + override var keyCommands: [UIKeyCommand]? { + var commands = super.keyCommands ?? [] + commands.append(findCommand(title: "Find Next in Terminal", modifiers: [])) + commands.append(findCommand(title: "Find Previous in Terminal", modifiers: .shift)) + + guard findController?.isPresented == true else { return commands } + commands.removeAll(where: isDismissFindEquivalent) + commands.append(dismissFindCommand(modifiers: [])) + commands.append(dismissFindCommand(modifiers: .shift)) + return commands + } + + @objc private func handleFindCommand(_ command: UIKeyCommand) { + findController?.presentAndNavigate(command.modifierFlags.contains(.shift) ? .next : .previous) + } + + @objc private func handleDismissFindCommand(_: UIKeyCommand) { + findController?.dismiss() + } + + private func findCommand(title: LocalizedStringResource, modifiers: UIKeyModifierFlags) -> UIKeyCommand { + let command = UIKeyCommand( + title: String(localized: title), + action: #selector(handleFindCommand(_:)), + input: UIKeyCommand.f3, + modifierFlags: modifiers + ) + command.wantsPriorityOverSystemBehavior = true + return command + } + + private func dismissFindCommand(modifiers: UIKeyModifierFlags) -> UIKeyCommand { + let command = UIKeyCommand( + title: String(localized: "Close Find"), + action: #selector(handleDismissFindCommand(_:)), + input: UIKeyCommand.inputEscape, + modifierFlags: modifiers + ) + command.wantsPriorityOverSystemBehavior = true + return command + } + + private func isDismissFindEquivalent(_ command: UIKeyCommand) -> Bool { + guard command.input == UIKeyCommand.inputEscape else { return false } + let modifiers = command.modifierFlags.intersection([.command, .control, .alternate, .shift]) + return modifiers.isEmpty || modifiers == .shift + } } diff --git a/TFI/UI/Components/MobileConnectionJourneyView.swift b/TFI/UI/Components/MobileConnectionJourneyView.swift index 938e29fb..861bc8ac 100644 --- a/TFI/UI/Components/MobileConnectionJourneyView.swift +++ b/TFI/UI/Components/MobileConnectionJourneyView.swift @@ -71,7 +71,10 @@ struct MobileConnectionJourneyView: View { HStack { Text("Connection Log").font(.headline) Spacer() - Button("Copy", action: copyLogs).controlSize(.small) + Button("Copy", action: copyLogs) + .controlSize(.small) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } .padding(14) Divider() @@ -138,20 +141,32 @@ private struct MobileJourneyFailureCard: View { VStack(spacing: 10) { HStack { Button("Cancel", action: cancel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) Button("Copy Logs", action: copyLogs) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) Spacer() } Button("Retry", action: retry) .buttonStyle(.borderedProminent) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity, alignment: .trailing) } } else { HStack { Button("Cancel", action: cancel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) Button("Copy Logs", action: copyLogs) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) Spacer() Button("Retry", action: retry) .buttonStyle(.borderedProminent) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } } diff --git a/TFI/UI/Components/MobileDropPayload.swift b/TFI/UI/Components/MobileDropPayload.swift index 462c7cc2..129821ab 100644 --- a/TFI/UI/Components/MobileDropPayload.swift +++ b/TFI/UI/Components/MobileDropPayload.swift @@ -20,18 +20,23 @@ nonisolated struct MobileDropPayload: Sendable { @MainActor enum MobileDropPayloadLoader { + private enum Representation { + case fileURL + case file(UTType) + case image(String) + } + + static func acceptsUpload(_ providers: [NSItemProvider]) -> Bool { + accepts(providers, kinds: [.files], fileMode: .staged) + } + static func accepts( _ providers: [NSItemProvider], - kinds: Set + kinds: Set, + fileMode: MobileDropFileMode = .referenced ) -> Bool { - providers.contains { provider in - if kinds.contains(.files), - provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) - { - return true - } - return kinds.contains(.images) - && provider.registeredTypeIdentifiers.contains(where: isImageType) + providers.contains { + representation(of: $0, kinds: kinds, fileMode: fileMode) != nil } } @@ -51,9 +56,9 @@ enum MobileDropPayloadLoader { } for provider in providers { do { - if kinds.contains(.files), - provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) - { + try Task.checkCancellation() + switch representation(of: provider, kinds: kinds, fileMode: fileMode) { + case .fileURL: guard let url = try await fileURL( from: provider, @@ -63,15 +68,25 @@ enum MobileDropPayloadLoader { throw CocoaError(.fileReadUnknown) } payload.fileURLs.append(url) - } else if kinds.contains(.images), - let identifier = provider.registeredTypeIdentifiers.first(where: isImageType) - { + case .file(let type): + if let root = payload.stagedRoot { + payload.fileURLs.append(try await fileRepresentation(from: provider, type: type, in: root)) + } + case .image(let identifier): payload.images.append(try await data(from: provider, typeIdentifier: identifier)) + case nil: + break } + } catch is CancellationError { + break } catch { payload.failures.append(error.localizedDescription) } } + if Task.isCancelled { + payload.fileURLs = [] + payload.images = [] + } if payload.fileURLs.isEmpty, let stagedRoot = payload.stagedRoot { try? FileManager.default.removeItem(at: stagedRoot) payload.stagedRoot = nil @@ -79,11 +94,36 @@ enum MobileDropPayloadLoader { return payload } + private static func representation( + of provider: NSItemProvider, kinds: Set, fileMode: MobileDropFileMode + ) -> Representation? { + if kinds.contains(.files), provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + return .fileURL + } + // Assistant image attachments keep their image semantics, even when files are staged. + if kinds.contains(.images), let identifier = provider.registeredTypeIdentifiers.first(where: isImageType) { + return .image(identifier) + } + guard kinds.contains(.files), fileMode == .staged, + !provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) + else { return nil } + let named = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + let inPlaceTypes = provider.registeredTypeIdentifiers(fileOptions: .openInPlace) + let type = provider.registeredTypeIdentifiers.compactMap(UTType.init).first { + if $0.conforms(to: .directory) { return true } + guard $0.conforms(to: .data), !$0.conforms(to: .url) else { return false } + // A text selection is terminal input. Files can advertise the same UTI, + // but supply a filename or an in-place file representation. + return !$0.conforms(to: .text) || named || inPlaceTypes.contains($0.identifier) + } + return type.map(Representation.file) + } + private static func isImageType(_ identifier: String) -> Bool { UTType(identifier)?.conforms(to: .image) == true } - private static func fileURL( + nonisolated private static func fileURL( from provider: NSItemProvider, stagingRoot: URL? ) async throws -> URL? { @@ -94,6 +134,10 @@ enum MobileDropPayloadLoader { return } if let url = item as? NSURL { + guard (url as URL).isFileURL else { + continuation.resume(throwing: CocoaError(.fileReadUnknown)) + return + } do { continuation.resume( returning: try stagingRoot.map { @@ -110,6 +154,37 @@ enum MobileDropPayloadLoader { } } + nonisolated private static func fileRepresentation( + from provider: NSItemProvider, type: UTType, in root: URL + ) async throws -> URL { + let suggestedName = provider.suggestedName + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in + do { + guard let url else { throw error ?? CocoaError(.fileReadUnknown) } + let name = stagedName(suggestedName, source: url, type: type) + // UIKit deletes its temporary representation when this callback returns. + continuation.resume(returning: try stage(url, in: root, name: name)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + nonisolated private static func stagedName(_ suggestedName: String?, source: URL, type: UTType) -> String { + var name = suggestedName.map { ($0 as NSString).lastPathComponent } ?? source.lastPathComponent + if name.isEmpty || name == "." || name == ".." || name == "/" { + name = source.lastPathComponent + } + if !type.conforms(to: .directory), (name as NSString).pathExtension.isEmpty, + let fileExtension = type.preferredFilenameExtension + { + name += "." + fileExtension + } + return name + } + private static func makeStagingRoot() throws -> URL { let parent = FileManager.default.temporaryDirectory.appending( path: "TermindDropUploads", directoryHint: .isDirectory) @@ -132,17 +207,29 @@ enum MobileDropPayloadLoader { return root } - nonisolated private static func stage(_ source: URL, in root: URL) throws -> URL { + nonisolated private static func stage(_ source: URL, in root: URL, name: String? = nil) throws -> URL { let itemRoot = root.appending(path: UUID().uuidString, directoryHint: .isDirectory) try FileManager.default.createDirectory(at: itemRoot, withIntermediateDirectories: true) - let destination = itemRoot.appending(path: source.lastPathComponent) + let destination = itemRoot.appending(path: name ?? source.lastPathComponent) let accessed = source.startAccessingSecurityScopedResource() defer { if accessed { source.stopAccessingSecurityScopedResource() } } - try FileManager.default.copyItem(at: source, to: destination) + do { + var coordinationError: NSError? + var copyResult: Result = .failure(CocoaError(.fileReadUnknown)) + NSFileCoordinator().coordinate(readingItemAt: source, options: .withoutChanges, error: &coordinationError) { + coordinatedURL in + copyResult = Result { try FileManager.default.copyItem(at: coordinatedURL, to: destination) } + } + if let coordinationError { throw coordinationError } + try copyResult.get() + } catch { + try? FileManager.default.removeItem(at: itemRoot) + throw error + } return destination } - private static func data( + nonisolated private static func data( from provider: NSItemProvider, typeIdentifier: String ) async throws -> Data { diff --git a/TFI/UI/Components/MobileWindowDropTarget.swift b/TFI/UI/Components/MobileWindowDropTarget.swift new file mode 100644 index 00000000..95244974 --- /dev/null +++ b/TFI/UI/Components/MobileWindowDropTarget.swift @@ -0,0 +1,192 @@ +import SwiftUI +import UIKit + +/// Uses the window for native drop routing, with a non-interactive view marking +/// the visible workspace's bounds. A terminal's own interaction forwards here too. +struct MobileWindowDropTarget: UIViewRepresentable { + @Binding var isTargeted: Bool + let isEnabled: Bool + let terminalView: UIView? + let receive: ([NSItemProvider]) -> Bool + + func makeCoordinator() -> Coordinator { + Coordinator(isEnabled: isEnabled, targeted: setTargeted, receive: receive) + } + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.coordinator = context.coordinator + return view + } + + func updateUIView(_ view: InstallerView, context: Context) { + context.coordinator.receive = receive + context.coordinator.targeted = setTargeted + context.coordinator.isEnabled = isEnabled + view.coordinator = context.coordinator + context.coordinator.install(in: view, terminalView: terminalView) + } + + static func dismantleUIView(_ view: InstallerView, coordinator: Coordinator) { + coordinator.uninstall() + view.coordinator = nil + } + + private func setTargeted(_ value: Bool) { + isTargeted = value + } + + final class InstallerView: UIView { + weak var coordinator: Coordinator? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func didMoveToWindow() { + super.didMoveToWindow() + coordinator?.moveToWindow() + } + } + + @MainActor + final class Coordinator: NSObject, UIDropInteractionDelegate { + var receive: ([NSItemProvider]) -> Bool + var targeted: (Bool) -> Void + var isEnabled: Bool { + didSet { + if !isEnabled { clearTarget() } + } + } + + private weak var region: UIView? + private weak var installedWindow: UIWindow? + private weak var terminalView: UIView? + private var terminalDropTarget: MobileTerminalDropTarget? + private lazy var interaction = UIDropInteraction(delegate: self) + private weak var activeInteraction: UIDropInteraction? + private weak var performedSession: (any UIDropSession)? + private var isTargeted = false + + init(isEnabled: Bool, targeted: @escaping (Bool) -> Void, receive: @escaping ([NSItemProvider]) -> Bool) { + self.isEnabled = isEnabled + self.targeted = targeted + self.receive = receive + } + + func install(in region: UIView, terminalView: UIView? = nil) { + self.region = region + if self.terminalView !== terminalView { + terminalDropTarget?.uninstall() + terminalDropTarget = nil + self.terminalView = terminalView + } + moveToWindow() + } + + func moveToWindow() { + let window = region?.window + if installedWindow !== window { + clearTarget() + installedWindow?.removeInteraction(interaction) + installedWindow = window + window?.addInteraction(interaction) + terminalDropTarget?.uninstall() + terminalDropTarget = nil + } + if window != nil, terminalDropTarget == nil, let terminalView { + terminalDropTarget = MobileTerminalDropTarget(view: terminalView, uploadDelegate: self) + } + } + + func uninstall() { + clearTarget() + installedWindow?.removeInteraction(interaction) + terminalDropTarget?.uninstall() + terminalDropTarget = nil + installedWindow = nil + terminalView = nil + region = nil + } + + private var isVisible: Bool { + guard isEnabled, let region, let window = installedWindow, region.window === window, + !region.bounds.isEmpty, !hasPresentation(in: window.rootViewController) + else { return false } + var ancestor: UIView? = region + while let view = ancestor { + if view.isHidden || view.alpha <= 0.01 { return false } + ancestor = view.superview + } + return true + } + + private func hasPresentation(in controller: UIViewController?) -> Bool { + guard let controller else { return false } + return controller.presentedViewController != nil || controller.children.contains { hasPresentation(in: $0) } + } + + private func accepts(_ session: any UIDropSession, atLocation: Bool) -> Bool { + guard isVisible, MobileDropPayloadLoader.acceptsUpload(session.items.map(\.itemProvider)), let region else { + return false + } + return !atLocation || region.bounds.contains(session.location(in: region)) + } + + private func updateTarget(_ value: Bool, interaction: UIDropInteraction) { + if value { + activeInteraction = interaction + } else if activeInteraction !== interaction { + return + } + if isTargeted != value { + isTargeted = value + targeted(value) + } + } + + private func clearTarget() { + activeInteraction = nil + if isTargeted { + isTargeted = false + targeted(false) + } + } + + func dropInteraction(_: UIDropInteraction, canHandle session: any UIDropSession) -> Bool { + accepts(session, atLocation: false) + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnter session: any UIDropSession) { + updateTarget(accepts(session, atLocation: true), interaction: interaction) + } + + func dropInteraction( + _ interaction: UIDropInteraction, sessionDidUpdate session: any UIDropSession + ) -> UIDropProposal { + let accepted = accepts(session, atLocation: true) + updateTarget(accepted, interaction: interaction) + return UIDropProposal(operation: accepted ? .copy : .cancel) + } + + func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit _: any UIDropSession) { + updateTarget(false, interaction: interaction) + } + + func dropInteraction(_: UIDropInteraction, sessionDidEnd _: any UIDropSession) { + clearTarget() + } + + func dropInteraction(_: UIDropInteraction, performDrop session: any UIDropSession) { + clearTarget() + guard accepts(session, atLocation: true), performedSession !== session else { return } + performedSession = session + _ = receive(session.items.map(\.itemProvider)) + } + } +} diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index d228c3c2..baa4b841 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -8,6 +8,7 @@ import TermindPersistence import TermindPresentation import TermindRemoteInfrastructure import Testing +import UIKit @testable import TermindIOS @@ -905,6 +906,26 @@ private actor MobileTestSFTPClient: SFTPClientProtocol { #expect(WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: value))) == nil) } } + + @Test func workspaceDropTargetInstallsItsNativeInteractionOnTheWindow() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1_024, height: 768)) + var targeted: [Bool] = [] + let coordinator = MobileWindowDropTarget.Coordinator( + isEnabled: true, + targeted: { targeted.append($0) }, + receive: { !$0.isEmpty } + ) + + let region = UIView(frame: window.bounds) + window.addSubview(region) + coordinator.install(in: region) + + #expect(window.interactions.contains { $0 is UIDropInteraction }) + coordinator.isEnabled = false + #expect(!targeted.contains(true)) + coordinator.uninstall() + #expect(!window.interactions.contains { $0 is UIDropInteraction }) + } } @Suite struct WorkspaceHostIconTests { diff --git a/TFITests/MobileAssistantRenderingTests.swift b/TFITests/MobileAssistantRenderingTests.swift index 6c1da15f..209ccf5c 100644 --- a/TFITests/MobileAssistantRenderingTests.swift +++ b/TFITests/MobileAssistantRenderingTests.swift @@ -51,7 +51,7 @@ struct MobileAssistantRenderingTests { "user-images", language: "en", view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) } ) } @@ -113,26 +113,26 @@ struct MobileAssistantRenderingTests { try await capture( "chat", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "/" try await capture( "slash", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "/skill " try await capture( "slash-options", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.draft = "" conversation.beginCompaction() try await capture( "compact-running", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.failCompaction( language == "en" ? "The context service is unavailable." : "上下文服务不可用。" @@ -140,7 +140,7 @@ struct MobileAssistantRenderingTests { try await capture( "compact-failed", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.endCompaction() conversation.appendUserTurn( @@ -155,14 +155,14 @@ struct MobileAssistantRenderingTests { try await capture( "compact-completed", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.attachmentImportError = language == "en" ? "The selected attachment could not be imported." : "无法导入所选附件。" try await capture( "attachment-error", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.composer.attachmentImportError = nil conversation.handle( @@ -199,7 +199,7 @@ struct MobileAssistantRenderingTests { try await capture( "question", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) conversation.cancelUserInput() conversation.archivedAt = .now @@ -207,13 +207,13 @@ struct MobileAssistantRenderingTests { try await capture( "archived-chat", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) app.assistant.newConversation() try await capture( "empty", language: language, view: NavigationStack { - MobileAssistantView(app: app, model: app.assistant) + MobileAssistantView(app: app, model: app.assistant, libraryRoute: .constant(nil)) }.environment(\.locale, Locale(identifier: language))) } } diff --git a/TFITests/MobileAssistantTranscriptTests.swift b/TFITests/MobileAssistantTranscriptTests.swift index 4b3ff105..7407561f 100644 --- a/TFITests/MobileAssistantTranscriptTests.swift +++ b/TFITests/MobileAssistantTranscriptTests.swift @@ -23,6 +23,13 @@ struct MobileAssistantTranscriptTests { #expect(long <= 320) } + @Test func activityUsesTheAvailableWidthToKeepItsTitleLeading() { + let controller = UIHostingController(rootView: MobileAssistantActivity()) + let size = controller.sizeThatFits(in: CGSize(width: 320, height: 1_000)) + + #expect(size.width == 320) + } + @Test func imageFlowKeepsEveryUserRowTrailingAndAssistantRowsLeading() { let tile = CGSize(width: 104, height: 104) let sizes = Array(repeating: tile, count: 5) diff --git a/TFITests/MobileCardRenderingTests.swift b/TFITests/MobileCardRenderingTests.swift index 2783091e..3695aa5b 100644 --- a/TFITests/MobileCardRenderingTests.swift +++ b/TFITests/MobileCardRenderingTests.swift @@ -96,7 +96,9 @@ struct MobileCardRenderingTests { for language in ["en", "zh-Hans"] { for scheme in [ColorScheme.light, .dark] { try await capture("vault", language: language, scheme: scheme) { - NavigationStack { MobileVaultView(app: app, open: { _, _ in }) } + NavigationStack { + MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + } } try await capture("snippets", language: language, scheme: scheme) { NavigationStack { MobileSnippetsView(app: app) } @@ -150,8 +152,10 @@ struct MobileCardRenderingTests { } } try await capture("large-type", language: language, scheme: .light) { - NavigationStack { MobileVaultView(app: app, open: { _, _ in }) } - .environment(\.dynamicTypeSize, .accessibility3) + NavigationStack { + MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + } + .environment(\.dynamicTypeSize, .accessibility3) } try await capture("metrics-large-type", language: language, scheme: .light) { MobileCardPage { @@ -164,7 +168,11 @@ struct MobileCardRenderingTests { ) } try await capture("empty", language: language, scheme: .light) { - NavigationStack { MobileVaultView(app: try! MobileAppModel(inMemory: true), open: { _, _ in }) } + NavigationStack { + MobileVaultView( + app: try! MobileAppModel(inMemory: true), editingHost: .constant(nil), + open: { _, _ in }) + } } try await capture("known-hosts-large-type", language: language, scheme: .light) { NavigationStack { MobileKnownHostsView(app: app) } diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 799bd550..584bd420 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -3,6 +3,7 @@ import TermindApplication import TermindDomain import TermindRemoteInfrastructure import Testing +import UniformTypeIdentifiers import struct TermindDomain.Host @@ -481,7 +482,21 @@ struct MobileUploadBatchTests { let looseFile = root.appending(path: "README.md") try Data("readme".utf8).write(to: looseFile) - #expect(workspace.prepareUpload([folder, looseFile])) + let providers = [(folder, UTType.folder), (looseFile, UTType.plainText)].map { url, type in + let provider = NSItemProvider() + provider.suggestedName = url.lastPathComponent + provider.registerFileRepresentation(forTypeIdentifier: type.identifier, fileOptions: [], visibility: .all) { + @Sendable completion in + completion(url, false, nil) + return nil + } + return provider + } + let payload = await MobileDropPayloadLoader.load(providers, kinds: [.files], fileMode: .staged) + defer { if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) } } + #expect(payload.failures.isEmpty) + try FileManager.default.removeItem(at: root) + #expect(workspace.prepareUpload(payload.fileURLs)) #expect(await eventually { transfers.sftpTransfers.count == 2 }) #expect( @@ -491,6 +506,12 @@ struct MobileUploadBatchTests { ] ) #expect(Set(transfers.sftpTransfers.map(\.batchID)).count == 1) + #expect(await eventuallyAsync { await client.uploadedData.count == 2 }) + #expect( + await client.uploadedData == [ + "/srv/incoming/README.md": Data("readme".utf8), + "/srv/incoming/bundle/nested/payload.txt": Data("payload".utf8), + ]) #expect(workspace.uploadNotice == .queued(2, "/srv/incoming")) #expect( await client.createdDirectories == [ @@ -654,6 +675,7 @@ struct MobileUploadBatchTests { } private actor MobileUploadSFTPClient: SFTPClientProtocol { + private(set) var uploadedData: [String: Data] = [:] private(set) var createdDirectories: [String] = [] private(set) var disconnectCount = 0 private let attributes: [String: RemoteFile] @@ -683,7 +705,9 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws {} + ) async throws { + uploadedData[remotePath] = try Data(contentsOf: localURL) + } func downloadFile( from remotePath: String, to localURL: URL, diff --git a/TFITests/MobileDropTests.swift b/TFITests/MobileDropTests.swift new file mode 100644 index 00000000..56adb1c0 --- /dev/null +++ b/TFITests/MobileDropTests.swift @@ -0,0 +1,443 @@ +import Foundation +import GhosttyTerminal +import SwiftUI +import TermindApplication +import Testing +import UIKit +import UniformTypeIdentifiers + +@testable import TermindIOS + +@Suite @MainActor +struct MobileDropPayloadTests { + @Test func fileRepresentationsWithoutFileURLsAreAcceptedAndSurviveTheProviderCallback() async throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "provider-temporary-name") + try Data("file bytes".utf8).write(to: source) + let provider = fileProvider(source, type: .plainText, name: "日志.log") + #expect(!provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)) + #expect(MobileDropPayloadLoader.acceptsUpload([provider])) + + let payload = await MobileDropPayloadLoader.load([provider], kinds: [.files], fileMode: .staged) + defer { removeStaging(payload) } + try FileManager.default.removeItem(at: source) + + let file = try #require(payload.fileURLs.first) + #expect(file.lastPathComponent == "日志.log") + #expect(try Data(contentsOf: file) == Data("file bytes".utf8)) + #expect(payload.failures.isEmpty) + } + + @Test func folderAndSameNamedFilesKeepTheirNamesAndSeparateContents() async throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let folder = root.appending(path: "folder", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: folder.appending(path: "nested"), withIntermediateDirectories: true) + try Data("nested".utf8).write(to: folder.appending(path: "nested/inside.txt")) + let first = root.appending(path: "first") + let second = root.appending(path: "second") + try Data("first".utf8).write(to: first) + try Data("second".utf8).write(to: second) + let providers = [ + fileProvider(folder, type: .folder, name: "bundle"), + fileProvider(first, type: .data, name: "same.bin"), + fileProvider(second, type: .data, name: "same.bin"), + ] + #expect(MobileDropPayloadLoader.acceptsUpload(providers)) + + let payload = await MobileDropPayloadLoader.load(providers, kinds: [.files], fileMode: .staged) + defer { removeStaging(payload) } + #expect(payload.failures.isEmpty) + #expect(payload.fileURLs.map(\.lastPathComponent) == ["bundle", "same.bin", "same.bin"]) + #expect(Set(payload.fileURLs).count == 3) + let stagedFolder = try #require(payload.fileURLs.first) + #expect(try Data(contentsOf: stagedFolder.appending(path: "nested/inside.txt")) == Data("nested".utf8)) + #expect( + try payload.fileURLs.dropFirst().map { try Data(contentsOf: $0) } == [ + Data("first".utf8), Data("second".utf8), + ]) + } + + @Test func ordinaryTextAndWebURLsStayOutOfUploadRouting() throws { + let text = NSItemProvider(object: "echo hello" as NSString) + let url = NSItemProvider(object: try #require(URL(string: "https://example.com")) as NSURL) + url.suggestedName = "A web link" + url.registerDataRepresentation(forTypeIdentifier: UTType.utf8PlainText.identifier, visibility: .all) { + @Sendable completion in + completion(Data("https://example.com".utf8), nil) + return nil + } + #expect(!MobileDropPayloadLoader.acceptsUpload([text])) + #expect(!MobileDropPayloadLoader.acceptsUpload([url])) + let fileURL = NSItemProvider(object: URL(fileURLWithPath: "/tmp/report.txt") as NSURL) + #expect(MobileDropPayloadLoader.acceptsUpload([fileURL])) + } + + @Test func unnamedInPlaceTextFilesAreAccepted() throws { + let provider = fileProvider(URL(fileURLWithPath: "/tmp/report.txt"), type: .plainText, name: nil, inPlace: true) + #expect(MobileDropPayloadLoader.acceptsUpload([provider])) + } + + @Test func aFailedRepresentationIsReportedAlongsideSuccessfulFiles() async throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "good.bin") + try Data([1, 2, 3]).write(to: source) + let good = fileProvider(source, type: .data, name: "good.bin") + let bad = failedFileProvider() + + let mixed = await MobileDropPayloadLoader.load([bad, good], kinds: [.files], fileMode: .staged) + defer { removeStaging(mixed) } + #expect(mixed.fileURLs.count == 1) + #expect(mixed.failures.count == 1) + + let failed = await MobileDropPayloadLoader.load([bad], kinds: [.files], fileMode: .staged) + #expect(failed.fileURLs.isEmpty) + #expect(failed.failures.count == 1) + #expect(failed.stagedRoot == nil) + } + + @Test func cancelledLoadingDoesNotReturnOrRetainStagedFiles() async throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appending(path: "slow.bin") + try Data([1]).write(to: source) + let provider = fileProvider(source, type: .data, name: "slow.bin", delay: 0.05) + let task = Task { await MobileDropPayloadLoader.load([provider], kinds: [.files], fileMode: .staged) } + try await Task.sleep(for: .milliseconds(10)) + task.cancel() + let payload = await task.value + #expect(payload.fileURLs.isEmpty) + #expect(payload.stagedRoot == nil) + } + + @Test func assistantImageClassificationAndReferencedFilesStayCompatible() async throws { + let image = NSItemProvider(item: Data([1, 2, 3]) as NSData, typeIdentifier: UTType.png.identifier) + image.suggestedName = "picture.png" + let payload = await MobileDropPayloadLoader.load([image], kinds: [.files, .images], fileMode: .staged) + defer { removeStaging(payload) } + #expect(payload.images == [Data([1, 2, 3])]) + #expect(payload.fileURLs.isEmpty) + #expect(!MobileDropPayloadLoader.accepts([image], kinds: [.files])) + #expect(MobileDropPayloadLoader.acceptsUpload([image])) + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeStaging(_ payload: MobileDropPayload) { + if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) } + } +} + +// Provider completion handlers can run away from MainActor, as real Files providers do. +nonisolated private func fileProvider( + _ source: URL, type: UTType, name: String?, inPlace: Bool = false, delay: TimeInterval = 0 +) -> NSItemProvider { + let provider = NSItemProvider() + provider.suggestedName = name + provider.registerFileRepresentation( + forTypeIdentifier: type.identifier, fileOptions: inPlace ? .openInPlace : [], visibility: .all + ) { completion in + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + completion(source, inPlace, nil) + } + return nil + } + return provider +} + +nonisolated private func failedFileProvider() -> NSItemProvider { + let provider = NSItemProvider() + provider.suggestedName = "missing.bin" + provider.registerFileRepresentation(forTypeIdentifier: UTType.data.identifier, fileOptions: [], visibility: .all) { + completion in + completion(nil, false, CocoaError(.fileReadNoSuchFile)) + return nil + } + return provider +} + +@Suite(.serialized) @MainActor +struct MobileWorkspaceDropTests { + @Test(arguments: MobileWorkspaceDescriptor.Kind.allCases) + func hostedWorkspacesRenderTheOverlayAcrossTheirContent(kind: MobileWorkspaceDescriptor.Kind) async throws { + let app = try MobileAppModel(inMemory: true) + let workspace = MobileWorkspace( + descriptor: MobileWorkspaceDescriptor(hostID: UUID(), title: "Drop Test", kind: kind), hosts: app.library) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + let controller = UIHostingController( + rootView: NavigationStack { + MobileWorkspaceView( + library: app.library, pro: app.pro, commandGenerator: app.commandGenerator, + workspace: workspace, workspaces: [workspace], selectWorkspace: { _ in }, + renameWorkspace: { _ in }, openSameDirectory: { _ in }, closeWorkspace: { _ in }) + } + .environment(\.scenePhase, .active) + .transaction { $0.disablesAnimations = true }) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + workspace.disconnect() + } + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(250)) + let region = try #require(dropRegion(in: controller.view)) + #expect(region.bounds.width > 100) + #expect(region.bounds.height > 100) + let interaction = try #require(window.interactions.compactMap { $0 as? UIDropInteraction }.first) + let delegate = try #require(interaction.delegate) + let session = TestDropSession( + providers: [fileProvider(URL(fileURLWithPath: "/tmp/report.txt"), type: .plainText, name: "report.txt")], + window: window) + session.point = region.convert(CGPoint(x: region.bounds.midX, y: region.bounds.midY), to: window) + #expect(delegate.dropInteraction?(interaction, canHandle: session) == true) + let before = snapshot(window) + delegate.dropInteraction?(interaction, sessionDidEnter: session) + try await Task.sleep(for: .milliseconds(100)) + let during = snapshot(window) + #expect(before != during) + Attachment.record(try #require(during), named: "\(kind.rawValue)-drop-overlay.png") + delegate.dropInteraction?(interaction, sessionDidEnd: session) + } + + private func dropRegion(in view: UIView) -> MobileWindowDropTarget.InstallerView? { + if let region = view as? MobileWindowDropTarget.InstallerView { return region } + return view.subviews.lazy.compactMap { dropRegion(in: $0) }.first + } + + private func snapshot(_ window: UIWindow) -> Data? { + UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + }.pngData() + } + + @Test func windowTargetTracksOnlyTheVisibleWorkspaceAndClearsOnExitOrDisable() throws { + let fixture = DropFixture() + defer { fixture.close() } + let session = fixture.fileSession() + #expect(fixture.coordinator.dropInteraction(fixture.interaction, canHandle: session)) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + #expect(fixture.targeted) + #expect(fixture.coordinator.dropInteraction(fixture.interaction, sessionDidUpdate: session).operation == .copy) + // Crossing into the sidebar stays in the window but leaves the workspace. + session.point = CGPoint(x: 40, y: 200) + #expect( + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidUpdate: session).operation == .cancel) + #expect(!fixture.targeted) + fixture.coordinator.dropInteraction(fixture.interaction, performDrop: session) + #expect(fixture.received == 0) + session.point = CGPoint(x: 400, y: 200) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidExit: session) + #expect(!fixture.targeted) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + fixture.coordinator.isEnabled = false + #expect(!fixture.targeted) + #expect(!fixture.coordinator.dropInteraction(fixture.interaction, canHandle: session)) + } + + @Test func terminalRoutingUploadsOnceAndPreservesTextAndLinkHandling() throws { + let fixture = DropFixture() + defer { fixture.close() } + let terminal = UIView(frame: fixture.region.bounds) + fixture.region.addSubview(terminal) + let originalDelegate = RecordingDropDelegate() + let originalInteraction = UIDropInteraction(delegate: originalDelegate) + terminal.addInteraction(originalInteraction) + fixture.coordinator.install(in: fixture.region, terminalView: terminal) + let interaction = try #require(terminal.interactions.compactMap { $0 as? UIDropInteraction }.first) + let delegate = try #require(interaction.delegate) + #expect(interaction !== originalInteraction) + let session = fixture.fileSession() + #expect(delegate.dropInteraction?(interaction, canHandle: session) == true) + delegate.dropInteraction?(interaction, sessionDidEnter: session) + #expect(fixture.targeted) + // A late exit from the window must not clear the terminal's targeting. + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidExit: session) + #expect(fixture.targeted) + delegate.dropInteraction?(interaction, performDrop: session) + fixture.coordinator.dropInteraction(fixture.interaction, performDrop: session) + #expect(fixture.received == 1) + #expect(!fixture.targeted) + #expect(originalDelegate.drops == 0) + for provider in [ + NSItemProvider(object: "echo hi" as NSString), + NSItemProvider(object: try #require(URL(string: "https://example.com")) as NSURL), + ] { + let textSession = TestDropSession(providers: [provider], window: fixture.window) + #expect(delegate.dropInteraction?(interaction, canHandle: textSession) == true) + let proposal = delegate.dropInteraction?(interaction, sessionDidUpdate: textSession) + #expect(proposal?.operation == .copy) + delegate.dropInteraction?(interaction, performDrop: textSession) + } + #expect(originalDelegate.drops == 2) + #expect(fixture.received == 1) + fixture.coordinator.uninstall() + #expect(terminal.interactions.contains { $0 === originalInteraction }) + } + + @Test func pinnedGhosttyInteractionIsReplacedAndRestored() throws { + let fixture = DropFixture() + defer { fixture.close() } + let terminal = MobileTerminalView(frame: .zero) + let original = try #require(terminal.interactions.compactMap { $0 as? UIDropInteraction }.first) + #expect(original.delegate === terminal) + fixture.coordinator.install(in: fixture.region, terminalView: terminal) + #expect(!terminal.interactions.contains { $0 === original }) + fixture.coordinator.uninstall() + #expect(terminal.interactions.contains { $0 === original }) + } + + @Test func reattachingATerminalBeforeOldDestinationTeardownPreservesTheOriginalDelegate() throws { + let terminal = MobileTerminalView(frame: .zero) + let original = try #require(terminal.interactions.compactMap { $0 as? UIDropInteraction }.first) + let firstDelegate = RecordingDropDelegate() + let secondDelegate = RecordingDropDelegate() + let first = try #require(MobileTerminalDropTarget(view: terminal, uploadDelegate: firstDelegate)) + let second = try #require(MobileTerminalDropTarget(view: terminal, uploadDelegate: secondDelegate)) + first.uninstall() + #expect(!terminal.interactions.contains { $0 === original }) + second.uninstall() + #expect(terminal.interactions.contains { $0 === original }) + #expect(original.delegate === terminal) + } + + @Test func hidingPresentingOrDetachingTheWorkspaceRejectsDrops() async throws { + let fixture = DropFixture() + defer { fixture.close() } + let session = fixture.fileSession() + fixture.region.isHidden = true + #expect(!fixture.coordinator.dropInteraction(fixture.interaction, canHandle: session)) + fixture.region.isHidden = false + fixture.window.rootViewController?.present(UIViewController(), animated: false) + #expect(fixture.window.rootViewController?.presentedViewController != nil) + #expect( + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidUpdate: session).operation == .cancel) + fixture.coordinator.dropInteraction(fixture.interaction, performDrop: session) + #expect(fixture.received == 0) + fixture.window.rootViewController?.dismiss(animated: false) + fixture.region.removeFromSuperview() + #expect(!fixture.coordinator.dropInteraction(fixture.interaction, canHandle: session)) + } + + @Test func movingBetweenWindowsRemovesTheOldInteractionAndClearsTargeting() throws { + let fixture = DropFixture() + let other = DropFixture() + defer { + fixture.close() + other.close() + } + let session = fixture.fileSession() + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + #expect(fixture.targeted) + other.window.rootViewController?.view.addSubview(fixture.region) + fixture.coordinator.moveToWindow() + #expect(!fixture.targeted) + #expect(!fixture.window.interactions.contains { $0 === fixture.interaction }) + #expect(other.window.interactions.contains { $0 === fixture.interaction }) + fixture.coordinator.uninstall() + #expect(other.window.interactions.contains { $0 === other.interaction }) + } + + @Test func endingOrUninstallingADragClearsTheOverlay() { + let fixture = DropFixture() + defer { fixture.close() } + let session = fixture.fileSession() + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnd: session) + #expect(!fixture.targeted) + fixture.coordinator.dropInteraction(fixture.interaction, sessionDidEnter: session) + fixture.coordinator.uninstall() + #expect(!fixture.targeted) + #expect(!fixture.window.interactions.contains { $0 === fixture.interaction }) + } +} + +@MainActor +private final class DropFixture { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 1_024, height: 768)) + let region = UIView(frame: CGRect(x: 300, y: 100, width: 724, height: 668)) + var targeted = false + var received = 0 + lazy var coordinator = MobileWindowDropTarget.Coordinator( + isEnabled: true, + targeted: { [weak self] in self?.targeted = $0 }, + receive: { [weak self] _ in + self?.received += 1 + return true + } + ) + var interaction: UIDropInteraction! + + init() { + window.rootViewController = UIViewController() + window.rootViewController?.view.addSubview(region) + window.isHidden = false + coordinator.install(in: region) + interaction = window.interactions.compactMap { $0 as? UIDropInteraction }.first + } + + func fileSession() -> TestDropSession { + TestDropSession( + providers: [fileProvider(URL(fileURLWithPath: "/tmp/report.txt"), type: .plainText, name: "report.txt")], + window: window) + } + + func close() { + coordinator.uninstall() + window.isHidden = true + window.rootViewController = nil + } +} + +@MainActor +private final class RecordingDropDelegate: NSObject, UIDropInteractionDelegate { + var drops = 0 + func dropInteraction(_: UIDropInteraction, canHandle _: any UIDropSession) -> Bool { true } + func dropInteraction(_: UIDropInteraction, sessionDidUpdate _: any UIDropSession) -> UIDropProposal { + UIDropProposal(operation: .copy) + } + func dropInteraction(_: UIDropInteraction, performDrop _: any UIDropSession) { drops += 1 } +} + +@MainActor +private final class TestDropSession: NSObject, UIDropSession { + let items: [UIDragItem] + weak var window: UIWindow? + var point = CGPoint(x: 400, y: 200) + let allowsMoveOperation = false + let isRestrictedToDraggingApplication = false + let localDragSession: (any UIDragSession)? = nil + nonisolated let progress = Progress(totalUnitCount: 0) + var progressIndicatorStyle = UIDropSessionProgressIndicatorStyle.none + + init(providers: [NSItemProvider], window: UIWindow) { + items = providers.map(UIDragItem.init(itemProvider:)) + self.window = window + } + + func location(in view: UIView) -> CGPoint { view.convert(point, from: window) } + func hasItemsConforming(toTypeIdentifiers identifiers: [String]) -> Bool { + items.contains { item in identifiers.contains { item.itemProvider.hasItemConformingToTypeIdentifier($0) } } + } + func canLoadObjects(ofClass aClass: any NSItemProviderReading.Type) -> Bool { + items.contains { $0.itemProvider.canLoadObject(ofClass: aClass) } + } + func loadObjects( + ofClass aClass: any NSItemProviderReading.Type, + completion: @escaping ([any NSItemProviderReading]) -> Void + ) -> Progress { + completion([]) + return progress + } +} diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 3dfeab57..58c7b217 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -51,7 +51,9 @@ struct MobileIconRenderingTests { for scheme in [ColorScheme.light, .dark] { let suffix = scheme == .dark ? "dark" : "light" try await capture("vault-\(suffix)", language: language, scheme: scheme) { - NavigationStack { MobileVaultView(app: app, open: { _, _ in }) }.environment(\.locale, locale) + NavigationStack { + MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + }.environment(\.locale, locale) } try await capture("snippets-\(suffix)", language: language, scheme: scheme) { NavigationStack { MobileSnippetsView(app: snippetApp) }.environment(\.locale, locale) diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index c2d340dc..0b018f8b 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -36,7 +36,7 @@ struct MobileNavigationParityTests { .components(separatedBy: "private func workspaceStatusTint").first) #expect(assistant.contains(".toolbarTitleMenu")) - #expect(assistant.contains("@State private var libraryRoute: MobileAssistantLibraryRoute?")) + #expect(assistant.contains("@Binding var libraryRoute: MobileAssistantLibraryRoute?")) #expect(assistant.contains(".navigationDestination(item: $libraryRoute)")) #expect(assistant.contains("List {")) #expect(!assistant.contains("MobileNativeList(")) @@ -344,7 +344,7 @@ struct MobileNavigationParityTests { #expect(rootSource.contains("NavigationStack { vault }")) #expect(rootSource.contains("case .vault: sidebarVault")) - #expect(rootSource.contains("app: app, open: open, showsMetrics: false")) + #expect(rootSource.contains("open: scene.open, showsMetrics: false")) #expect(vaultSource.contains("guard showsMetrics else { return [] }")) #expect(vaultSource.contains("if showsMetrics {")) } @@ -433,7 +433,7 @@ struct MobileNavigationParityTests { #expect(workspaceList.contains(".contextMenu {")) #expect( workspaceList.contains( - "Button(\"Close\", systemImage: \"xmark\", role: .destructive) { closeWorkspace(workspace.id) }")) + "Button(\"Close\", systemImage: \"xmark\", role: .destructive) { scene.closeWorkspace(workspace.id) }")) let toolbar = try String( contentsOf: root.appending(path: "TFI/Features/HostChooser/MobileConnectionPicker.swift"), encoding: .utf8) @@ -461,9 +461,11 @@ struct MobileNavigationParityTests { ) #expect(workspace.contains("Button(\"Generate Command\", systemImage: \"sparkles\")")) - #expect(workspace.contains(".keyboardShortcut(\"i\", modifiers: .command)")) - #expect(rootSurface.contains("of: [.fileURL]")) - #expect(rootSurface.contains("perform: receiveUploadProviders")) + #expect(!workspace.contains(".keyboardShortcut(")) + #expect(rootSurface.contains("MobileWindowDropTarget(")) + #expect(rootSurface.contains("terminalView: workspace.uploadTerminalView")) + #expect(!rootSurface.contains(".frame(width: 0, height: 0)")) + #expect(rootSurface.contains("receive: receiveUploadProviders")) #expect(rootSurface.contains("DropTargetOverlay(presentation: uploadDropPresentation)")) #expect(workspace.contains("allowedContentTypes: [.data, .folder]")) #expect(workspace.contains("allowsMultipleSelection: true")) diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift new file mode 100644 index 00000000..ab932871 --- /dev/null +++ b/TFITests/MobileShortcutTests.swift @@ -0,0 +1,193 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import Testing +import UIKit + +import struct TermindDomain.Host + +@testable import TermindIOS + +@Suite @MainActor +struct MobileShortcutTests { + @Test func shortcutMapMatchesDesktopAndHasNoDuplicates() { + #expect(MobileShortcut.settings == KeyboardShortcut(",", modifiers: .command)) + #expect(MobileShortcut.connect == KeyboardShortcut("t", modifiers: .command)) + #expect(MobileShortcut.newChat == KeyboardShortcut("t", modifiers: [.command, .option])) + #expect(MobileShortcut.newHost == KeyboardShortcut("n", modifiers: [.command, .shift])) + #expect(MobileShortcut.generateCommand == KeyboardShortcut("i", modifiers: .command)) + #expect(MobileShortcut.find == KeyboardShortcut("f", modifiers: .command)) + #expect(MobileShortcut.findNext == KeyboardShortcut("g", modifiers: .command)) + #expect(MobileShortcut.findPrevious == KeyboardShortcut("g", modifiers: [.command, .shift])) + #expect(MobileShortcut.closeWorkspace == KeyboardShortcut("w", modifiers: .command)) + #expect(MobileShortcut.nextWorkspace == KeyboardShortcut("]", modifiers: [.command, .shift])) + #expect(MobileShortcut.previousWorkspace == KeyboardShortcut("[", modifiers: [.command, .shift])) + #expect(MobileShortcut.send == KeyboardShortcut(.return, modifiers: .command)) + #expect(MobileShortcut.stop == KeyboardShortcut(".", modifiers: .command)) + + let shortcuts = + [ + MobileShortcut.settings, + MobileShortcut.connect, + MobileShortcut.newChat, + MobileShortcut.newHost, + MobileShortcut.generateCommand, + MobileShortcut.find, + MobileShortcut.findNext, + MobileShortcut.findPrevious, + MobileShortcut.closeWorkspace, + MobileShortcut.nextWorkspace, + MobileShortcut.previousWorkspace, + MobileShortcut.send, + MobileShortcut.stop, + ] + (1...9).map(MobileShortcut.workspace) + #expect(Set(shortcuts).count == shortcuts.count) + } + + @Test func sceneCommandsNavigateToTheirMobileDestinations() throws { + let app = try MobileAppModel(inMemory: true) + let scene = MobileSceneModel(app: app) + + scene.openSettings() + #expect(scene.selectedDestination == .settings) + + scene.openConnectionPicker() + #expect(scene.selectedDestination == .workspaces) + #expect(scene.workspaceID == nil) + #expect(scene.isConnectionPickerPresented) + + scene.openNewHost() + #expect(scene.selectedDestination == .vault) + #expect(scene.editingHost != nil) + + scene.assistantLibraryRoute = .archived + let previousConversationID = app.assistant.conversation.id + scene.openNewConversation() + #expect(scene.selectedDestination == .assistant) + #expect(scene.assistantLibraryRoute == nil) + #expect(app.assistant.conversation.id != previousConversationID) + } + + @Test func workspaceCommandsSelectByIndexAndWrapInBothDirections() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let first = try #require(app.open(hostID: host.id, kind: .ssh)) + let second = try #require(app.open(hostID: host.id, kind: .sftp)) + let third = try #require(app.open(hostID: host.id, kind: .ssh)) + defer { app.closeAll() } + let scene = MobileSceneModel(app: app) + + scene.openSettings() + scene.selectNextWorkspace() + #expect(scene.workspaceID == first) + #expect(scene.selectedDestination == .workspaces) + + scene.openSettings() + scene.selectPreviousWorkspace() + #expect(scene.workspaceID == third) + + scene.selectNextWorkspace() + #expect(scene.workspaceID == first) + + scene.selectWorkspace(at: 1) + #expect(scene.workspaceID == second) + #expect(!scene.canUseTerminalCommands) + + scene.selectLastWorkspace() + #expect(scene.workspaceID == third) + #expect(scene.canUseTerminalCommands) + scene.presentTerminalFind() + #expect(scene.activeWorkspace?.find.isPresented == true) + scene.activeWorkspace?.find.dismiss() + + scene.selectWorkspace(at: 99) + #expect(scene.workspaceID == third) + } + + @Test func closeCommandOnlyClosesAWorkspaceVisibleInThisScene() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let first = try #require(app.open(hostID: host.id, kind: .ssh)) + let second = try #require(app.open(hostID: host.id, kind: .sftp)) + defer { app.closeAll() } + let scene = MobileSceneModel(app: app) + let otherScene = MobileSceneModel(app: app) + otherScene.selectWorkspace(second) + + scene.selectWorkspace(first) + #expect(scene.canCloseWorkspace) + scene.openSettings() + #expect(!scene.canCloseWorkspace) + scene.closeActiveWorkspace() + #expect(app.workspaces.map(\.id) == [first, second]) + + scene.selectWorkspace(first) + scene.closeActiveWorkspace() + #expect(app.workspaces.map(\.id) == [second]) + #expect(scene.workspaceID == nil) + #expect(otherScene.workspaceID == second) + #expect(otherScene.activeWorkspace?.id == second) + } + + @Test func terminalFunctionKeysFollowDesktopFindDirection() throws { + var actions: [String] = [] + let find = TerminalFindController(perform: { actions.append($0) }, focus: {}) + let view = MobileTerminalView(frame: .zero) + view.findController = find + find.updateQuery("needle") + actions.removeAll() + + let next = try keyCommand(in: view, input: UIKeyCommand.f3, modifiers: []) + #expect(UIApplication.shared.sendAction(try #require(next.action), to: view, from: next, for: nil)) + #expect( + actions == [ + TerminalFindBinding.search("needle"), + TerminalFindBinding.navigate(.previous), + ]) + + actions.removeAll() + let previous = try keyCommand(in: view, input: UIKeyCommand.f3, modifiers: .shift) + #expect(UIApplication.shared.sendAction(try #require(previous.action), to: view, from: previous, for: nil)) + #expect(actions == [TerminalFindBinding.navigate(.next)]) + } + + @Test func terminalEscapeReturnsToGhosttyUnlessFindIsPresented() throws { + var actions: [String] = [] + var focusCount = 0 + let find = TerminalFindController( + perform: { actions.append($0) }, + focus: { focusCount += 1 } + ) + let view = MobileTerminalView(frame: .zero) + view.findController = find + + let terminalEscape = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: []) + #expect(!NSStringFromSelector(try #require(terminalEscape.action)).contains("handleDismissFindCommand")) + + find.present() + let dismiss = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: []) + #expect(NSStringFromSelector(try #require(dismiss.action)).contains("handleDismissFindCommand")) + #expect(UIApplication.shared.sendAction(try #require(dismiss.action), to: view, from: dismiss, for: nil)) + #expect(!find.isPresented) + #expect(actions == [TerminalFindBinding.end]) + #expect(focusCount == 1) + + let shifted = try keyCommand(in: view, input: UIKeyCommand.inputEscape, modifiers: .shift) + #expect(!NSStringFromSelector(try #require(shifted.action)).contains("handleDismissFindCommand")) + } + + private func keyCommand( + in view: MobileTerminalView, + input: String, + modifiers: UIKeyModifierFlags + ) throws -> UIKeyCommand { + try #require( + view.keyCommands?.first { + $0.input == input + && $0.modifierFlags.intersection([.command, .control, .alternate, .shift]) == modifiers + } + ) + } +} diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 9e769a69..c6381d01 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -565,7 +565,13 @@ struct ArchitectureBoundaryTests { #expect(workspace.contains(".toolbar(.hidden, for: .tabBar)")) #expect(workspace.contains("guard let text = UIPasteboard.general.string else { return }")) #expect(workspace.contains("workspace.insertTerminalText(text)")) - #expect(root.contains("private func closeWorkspace(_ id: UUID)")) + let mobileScene = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileSceneModel.swift"), encoding: .utf8) + let mobileCommands = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileCommands.swift"), encoding: .utf8) + #expect(root.contains(".focusedSceneValue(\\.mobileScene, scene)")) + #expect(mobileScene.contains("func closeWorkspace(_ id: MobileWorkspace.ID)")) + #expect(mobileCommands.contains("@FocusedValue(\\.mobileScene)")) #expect(!workspace.contains("Circle().fill(workspace.connected")) let keyboard = try String( contentsOf: sourceRoot.appending(path: "../TFI/Features/Terminal/MobileTerminalKeyboard.swift"),