This commit is contained in:
Kaede Akino committed 2026-09-12 23:24:04 +08:00
1 parent f6d4e71451
commit c036f1a058
30 files changed
+1798 -216

No files matched your search

+35 -1
View File
@@ -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"
}
}
@@ -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)
@@ -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)…") }
+17
View File
@@ -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)
+105
View File
@@ -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)
}
}
}
+45 -103
View File
@@ -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
}
}
+187
View File
@@ -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()
}
}
+1
View File
@@ -31,5 +31,6 @@ struct TermindIOSApp: App {
description: Text("The local data store could not be opened. Please restart the app."))
}
}
.commands { MobileCommands() }
}
}
@@ -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 {
@@ -104,6 +104,7 @@ struct MobileAssistantActivity: View {
}
.foregroundStyle(.secondary)
.modifier(MobileAssistantScanning(active: true))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -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<MobileAssistantLibraryRoute?>
) {
self.app = app
self.model = model
_libraryRoute = libraryRoute
}
var body: some View {
detail
.navigationTitle(
+2 -2
View File
@@ -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")
}
+3 -1
View File
@@ -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<Host?>,
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
@@ -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() {
@@ -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<Void, Never>?
@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<MobileDropKind> = [.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
@@ -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)
}
}
+62 -1
View File
@@ -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
}
}
@@ -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)
}
}
}
+107 -20
View File
@@ -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<MobileDropKind>
kinds: Set<MobileDropKind>,
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<MobileDropKind>, 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<Void, any Error> = .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 {
@@ -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))
}
}
}
+21
View File
@@ -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 {
+11 -11
View File
@@ -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)))
}
}
@@ -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)
+12 -4
View File
@@ -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) }
@@ -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,
+443
View File
@@ -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
}
}
+3 -1
View File
@@ -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)
+8 -6
View File
@@ -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"))
+193
View File
@@ -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
}
)
}
}
+7 -1
View File
@@ -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"),