Bulk TFI
This commit is contained in:
1 parent
f6d4e71451
commit
c036f1a058
30 files changed
+1798
-216
No files matched your search
@@ -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)…") }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user