Add TFI workspace rename and directory handoff

This commit is contained in:
Kaede Akino committed 2026-09-12 10:38:00 +08:00
1 parent ef49664e87
commit fcba944911
15 files changed
+389 -41

No files matched your search

@@ -0,0 +1,14 @@
import TermindDomain
import Testing
@Suite
struct RemoteShellCommandTests {
@Test
func directoryChangesQuoteRemotePaths() {
#expect(
RemoteShellCommand.changeDirectory(to: "/srv/it's live")
== "cd '/srv/it'\"'\"'s live'"
)
#expect(RemoteShellCommand.changeDirectory(to: "") == nil)
}
}
@@ -0,0 +1,14 @@
import Foundation
/// Shell commands generated from remote session context rather than typed by the user.
public nonisolated enum RemoteShellCommand {
/// Builds a one-shot directory change for a newly opened shell.
///
/// Single-quoting keeps spaces and shell punctuation inert; embedded
/// apostrophes are closed, emitted literally, and reopened.
public static func changeDirectory(to path: String) -> String? {
guard !path.isEmpty else { return nil }
let quoted = path.replacingOccurrences(of: "'", with: "'\"'\"'")
return "cd '\(quoted)'"
}
}
+12 -3
View File
@@ -615,9 +615,11 @@ An open mobile Workspace switches among its peers through the navigation-title
menu; switching alone neither connects nor claims keyboard focus. Workspace
chrome exposes no manual Connect or Disconnect action: an initial failure retries
through its Journey, an established session recovers through the shared reconnect
policy, and the More menu closes the Workspace. Explicit closes never schedule
retries. Background-task expiration closes transports without discarding the
connection intent or Journey; the next foreground activation immediately resumes
policy, and the More menu closes the Workspace. A mobile Workspace name is stored
in its restoration descriptor and a rename updates the matching Live Activity
session snapshot. The Workspace list and detail More menu are the two rename
surfaces. Explicit closes never schedule retries. Background-task expiration closes
transports without discarding the connection intent or Journey; the next foreground activation immediately resumes
those Workspaces. Cold-launch restoration remains lazy and starts only the Workspace
the user opens. Connection, authentication, host-key and
reconnection failures remain inside Journey and never fall through to a second
@@ -665,6 +667,13 @@ expiration of background execution cancels its work; no transfer is silently rep
Downloads are staged in a unique app-temporary directory and exported through the system
share sheet. Remote names are validated before becoming local path components.
The first item in a mobile Workspace's More menu opens the same live remote directory
in a new Workspace of the opposite kind. SSH offers SFTP only after Shell Integration
has reported a working directory; SFTP offers SSH only while its browser is connected.
Unavailable actions are omitted. The initial SFTP path and the SSH shell's generated,
history-excluded `cd` command survive authentication/trust retries but remain live-only;
neither enters restoration metadata nor replays on reconnect.
Mobile host deletion records the existing deletion saga and starts the shared outbox
runner. Restored pending deletions are hidden before workspace restoration. The runner's
start/stop generation prevents a cancelled background loop from clearing a replacement
+51 -5
View File
@@ -10,6 +10,12 @@ import TermindRemoteInfrastructure
import struct TermindDomain.Host
private enum MobileWorkspaceStartup {
case none
case command(String)
case directory(String)
}
@MainActor
@Observable
final class MobileAppModel {
@@ -171,10 +177,18 @@ final class MobileAppModel {
func open(
hostID: UUID, kind: MobileWorkspaceDescriptor.Kind,
initialCommand: String? = nil
initialPath: String? = nil
) -> UUID? {
guard let host = library.host(hostID) else { return nil }
let workspace = makeWorkspace(host: host, kind: kind, initialCommand: initialCommand)
let startup: MobileWorkspaceStartup
if let initialPath,
!initialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
startup = .directory(RemotePath.normalized(initialPath))
} else {
startup = .none
}
let workspace = makeWorkspace(host: host, kind: kind, startup: startup)
addWorkspaces([workspace])
return workspace.id
}
@@ -184,11 +198,20 @@ final class MobileAppModel {
func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) -> [MobileWorkspace.ID] {
let wanted = Set(hostIDs)
let targets = library.searchHosts("").filter { wanted.contains($0.id) }
let opened = targets.map { makeWorkspace(host: $0, kind: .ssh, initialCommand: snippet.body) }
let opened = targets.map { makeWorkspace(host: $0, kind: .ssh, startup: .command(snippet.body)) }
addWorkspaces(opened)
return opened.map(\.id)
}
@discardableResult
func renameWorkspace(_ id: MobileWorkspace.ID, to title: String) -> Bool {
guard let workspace = workspaces.first(where: { $0.id == id }), workspace.rename(to: title) else {
return false
}
persistWorkspaces()
return true
}
func close(_ id: UUID) {
guard let workspace = workspaces.first(where: { $0.id == id }) else { return }
workspace.didChangeState = nil
@@ -324,14 +347,37 @@ final class MobileAppModel {
}
private func makeWorkspace(
host: Host, kind: MobileWorkspaceDescriptor.Kind, initialCommand: String?
host: Host, kind: MobileWorkspaceDescriptor.Kind, startup: MobileWorkspaceStartup = .none
) -> MobileWorkspace {
connectionHistory.record(kind: kind == .ssh ? .ssh : .sftp, for: host.id)
let initialCommand: String?
let recordsInitialCommandInHistory: Bool
let initialSFTPPath: String?
switch (kind, startup) {
case (.ssh, .command(let command)):
initialCommand = command
recordsInitialCommandInHistory = true
initialSFTPPath = nil
case (.ssh, .directory(let path)):
initialCommand = RemoteShellCommand.changeDirectory(to: path)
recordsInitialCommandInHistory = false
initialSFTPPath = nil
case (.sftp, .directory(let path)):
initialCommand = nil
recordsInitialCommandInHistory = true
initialSFTPPath = path
case (.ssh, .none), (.sftp, .none), (.sftp, .command):
initialCommand = nil
recordsInitialCommandInHistory = true
initialSFTPPath = nil
}
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, terminalSettings: terminalSettings,
commandHistory: commandHistory, connectionServices: workspaceConnectionServices,
initialCommand: initialCommand)
initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory,
initialSFTPPath: initialSFTPPath)
}
private func addWorkspaces(_ added: [MobileWorkspace]) {
+24 -1
View File
@@ -15,6 +15,7 @@ struct MobileRootView: View {
@State private var workspaceID: UUID?
@State private var selectingConnection = false
@State private var unavailable = false
@State private var renamingWorkspace: MobileWorkspace?
var body: some View {
Group {
@@ -55,6 +56,11 @@ struct MobileRootView: View {
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
MobileProView(pro: app.pro)
}
.sheet(item: $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: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
.onChange(of: app.workspaces.map(\.id)) { _, workspaceIDs in
@@ -137,7 +143,10 @@ struct MobileRootView: View {
MobileWorkspaceView(
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: app.workspaces,
selectWorkspace: selectWorkspace, closeWorkspace: closeWorkspace
selectWorkspace: selectWorkspace,
renameWorkspace: beginWorkspaceRename,
openSameDirectory: openSameDirectory,
closeWorkspace: closeWorkspace
)
.onAppear { workspace.activateWhenOpened() }
}
@@ -193,6 +202,8 @@ struct MobileRootView: View {
Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) }
}
.contextMenu {
Button("Rename", systemImage: "pencil") { beginWorkspaceRename(workspace) }
Divider()
Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) }
}
}
@@ -237,6 +248,18 @@ struct MobileRootView: View {
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 }
+79 -11
View File
@@ -30,12 +30,17 @@ private struct MobilePendingUpload {
var connectionGeneration: UUID
}
private struct MobilePendingInitialCommand {
var command: String
var recordsInHistory: Bool
}
@MainActor
@Observable
final class MobileWorkspace: Identifiable {
typealias ID = UUID
let descriptor: MobileWorkspaceDescriptor
private(set) var descriptor: MobileWorkspaceDescriptor
var id: UUID { descriptor.id }
@ObservationIgnored private(set) var activitySession: WorkspaceActivityAttributes.Session
@ObservationIgnored var didChangeState: ((WorkspaceActivityAttributes.Session) -> Void)?
@@ -80,7 +85,10 @@ final class MobileWorkspace: Identifiable {
)
private var activeHistoryID: UUID?
private var shellIntegrationAvailable = false
private(set) var pendingInitialCommand: String?
private var pendingCommand: MobilePendingInitialCommand?
private var commandExcludedFromHistory: String?
private var pendingInitialSFTPPath: String?
var pendingInitialCommand: String? { pendingCommand?.command }
private(set) var workingDirectory: String?
var commandGeneratorState: CommandGeneratorState { commandGeneration.state }
private var integratedShell: String?
@@ -107,6 +115,30 @@ final class MobileWorkspace: Identifiable {
var uploadConflictCount: Int { pendingUpload?.batch.conflictCount ?? 0 }
var hasPendingUploadConflict: Bool { uploadConflictCount > 0 }
var sameDirectoryLaunch: MobileWorkspaceDirectoryLaunch? {
guard connected else { return nil }
switch descriptor.kind {
case .ssh:
guard terminalSettings.preferences.shellIntegration,
shellIntegrationAvailable,
let workingDirectory,
!workingDirectory.isEmpty
else { return nil }
return MobileWorkspaceDirectoryLaunch(
hostID: descriptor.hostID,
kind: .sftp,
path: RemotePath.normalized(workingDirectory)
)
case .sftp:
guard !sftpSession.path.isEmpty else { return nil }
return MobileWorkspaceDirectoryLaunch(
hostID: descriptor.hostID,
kind: .ssh,
path: RemotePath.normalized(sftpSession.path)
)
}
}
var uploadDestination: String? {
switch descriptor.kind {
case .ssh:
@@ -144,6 +176,8 @@ final class MobileWorkspace: Identifiable {
connectionServices: MobileWorkspaceConnectionServices = .live,
reconnectController: SessionReconnectController = SessionReconnectController(),
initialCommand: String? = nil,
recordsInitialCommandInHistory: Bool = true,
initialSFTPPath: String? = nil,
connectsWhenOpened: Bool = false
) {
self.terminalSettings = terminalSettings
@@ -156,7 +190,12 @@ final class MobileWorkspace: Identifiable {
connected: false,
iconAsset: LinuxDistributionIconCatalog.entry(
for: hosts.host(descriptor.hostID)?.detectedDistribution)?.asset)
pendingInitialCommand = initialCommand
pendingCommand = initialCommand.map {
MobilePendingInitialCommand(command: $0, recordsInHistory: recordsInitialCommandInHistory)
}
pendingInitialSFTPPath = initialSFTPPath.flatMap { path in
path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : RemotePath.normalized(path)
}
self.connectionServices = connectionServices
connection = RemoteSessionConnectionController(reconnect: reconnectController)
connectionResumeIntent = connectsWhenOpened ? .whenOpened : .none
@@ -190,6 +229,16 @@ final class MobileWorkspace: Identifiable {
terminalTextSelection = nil
}
@discardableResult
func rename(to proposedTitle: String) -> Bool {
let title = proposedTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty, title != descriptor.title else { return false }
descriptor.title = title
activitySession.title = title
didChangeState?(activitySession)
return true
}
private func makeAuthenticationPrompt() -> SSHAuthenticationPrompt? {
guard authenticationRequired, let host = hosts.host(authenticationHostID ?? descriptor.hostID) else {
return nil
@@ -370,6 +419,7 @@ final class MobileWorkspace: Identifiable {
isShellPromptReady = false
shellIntegrationAvailable = false
workingDirectory = nil
commandExcludedFromHistory = nil
commandDraft.cancel()
commandGeneratorRevision &+= 1
}
@@ -409,7 +459,12 @@ final class MobileWorkspace: Identifiable {
guard shellIntegrationAvailable else { return }
isShellPromptReady = false
invalidateCommandGeneration()
activeHistoryID = commandHistory.record(command, for: descriptor.hostID)?.id
if command == commandExcludedFromHistory {
commandExcludedFromHistory = nil
activeHistoryID = nil
} else {
activeHistoryID = commandHistory.record(command, for: descriptor.hostID)?.id
}
case .commandFinished(let status, let duration):
// CommandFinished is emitted from the prompt hook immediately before
// cwd and prompt reporting, so it is also a trustworthy recovery
@@ -527,11 +582,16 @@ final class MobileWorkspace: Identifiable {
return
}
sftp = client
let initialPath =
reconnecting
? sftpSession.path
: host.sftpSessionInitialPath.isEmpty
? try await client.homeDirectory() : host.sftpSessionInitialPath
let initialPath: String
if reconnecting {
initialPath = sftpSession.path
} else if let pendingInitialSFTPPath {
initialPath = pendingInitialSFTPPath
} else if host.sftpSessionInitialPath.isEmpty {
initialPath = try await client.homeDirectory()
} else {
initialPath = host.sftpSessionInitialPath
}
try await sftpSession.attach(
fileSystem: client,
path: initialPath,
@@ -543,6 +603,7 @@ final class MobileWorkspace: Identifiable {
self?.cancelPendingUpload()
}
)
pendingInitialSFTPPath = nil
journey.receive(.connected(host.id))
}
guard generation == token, !Task.isCancelled else { return }
@@ -626,8 +687,12 @@ final class MobileWorkspace: Identifiable {
/// Consumes the launch-only command before it is written. Reconnects and
/// restored descriptors therefore cannot replay a script.
func consumePendingInitialCommand() -> String? {
defer { pendingInitialCommand = nil }
return pendingInitialCommand
guard let pendingCommand else { return nil }
self.pendingCommand = nil
if !pendingCommand.recordsInHistory {
commandExcludedFromHistory = pendingCommand.command
}
return pendingCommand.command
}
func retryReconnectNow() {
@@ -793,6 +858,9 @@ final class MobileWorkspace: Identifiable {
submissions.removeAll()
activeHistoryID = nil
pendingCommand = nil
pendingInitialSFTPPath = nil
commandExcludedFromHistory = nil
generation = UUID()
attempt?.cancel()
attempt = nil
@@ -14,3 +14,9 @@ nonisolated struct MobileWorkspaceDescriptor: Codable, Hashable, Identifiable, S
var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) }
}
nonisolated struct MobileWorkspaceDirectoryLaunch: Equatable, Sendable {
var hostID: UUID
var kind: MobileWorkspaceDescriptor.Kind
var path: String
}
@@ -0,0 +1,29 @@
import Foundation
import SwiftUI
struct MobileWorkspaceRenameView: View {
let originalTitle: String
let save: (String) -> Void
@State private var title: String
init(workspace: MobileWorkspace, save: @escaping (String) -> Void) {
originalTitle = workspace.descriptor.title
self.save = save
_title = State(initialValue: workspace.descriptor.title)
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Rename"),
isDirty: normalizedTitle != originalTitle,
canSave: !normalizedTitle.isEmpty && normalizedTitle != originalTitle,
compact: true,
save: { save(normalizedTitle) },
content: { TextField("Workspace Name", text: $title) }
)
}
private var normalizedTitle: String {
title.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -22,6 +22,8 @@ struct MobileWorkspaceView: View {
@Bindable var workspace: MobileWorkspace
let workspaces: [MobileWorkspace]
let selectWorkspace: (UUID) -> Void
let renameWorkspace: (MobileWorkspace) -> Void
let openSameDirectory: (MobileWorkspaceDirectoryLaunch) -> Void
let closeWorkspace: (UUID) -> Void
@State private var terminalSheet: TerminalSheet?
@State private var importing = false
@@ -127,6 +129,9 @@ struct MobileWorkspaceView: View {
.toolbar {
if workspace.descriptor.kind == .ssh {
Menu {
sameDirectoryMenuAction
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
Divider()
Button("Generate Command", systemImage: "sparkles") {
workspace.generateCommand(using: commandGenerator)
}
@@ -153,6 +158,9 @@ struct MobileWorkspaceView: View {
}
if workspace.descriptor.kind == .sftp {
Menu {
sameDirectoryMenuAction
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
Divider()
Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true }
Divider()
Button("Parent Directory", systemImage: "arrow.up") {
@@ -323,6 +331,19 @@ struct MobileWorkspaceView: View {
workspace.operationError ?? workspace.sftpSession.operationError
}
@ViewBuilder
private var sameDirectoryMenuAction: some View {
if let launch = workspace.sameDirectoryLaunch {
switch launch.kind {
case .ssh:
Button("Open with SSH", systemImage: "terminal") { openSameDirectory(launch) }
case .sftp:
Button("Open with SFTP", systemImage: "folder") { openSameDirectory(launch) }
}
Divider()
}
}
private var showsJourney: Bool {
workspace.journey.isPresented || workspace.authenticationRequired || workspace.hostKeyPrompt != nil
}
+4 -2
View File
@@ -48,7 +48,8 @@ struct MobileAISettingsToolbarTests {
MobileWorkspaceView(
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: [workspace],
selectWorkspace: { _ in }, closeWorkspace: { _ in })
selectWorkspace: { _ in }, renameWorkspace: { _ in },
openSameDirectory: { _ in }, closeWorkspace: { _ in })
) { names in
#expect(names.contains("More"))
#expect(!names.contains("History"))
@@ -151,7 +152,8 @@ private struct WorkspaceTabBarHarness: View {
MobileWorkspaceView(
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: [workspace],
selectWorkspace: { _ in }, closeWorkspace: { _ in })
selectWorkspace: { _ in }, renameWorkspace: { _ in },
openSameDirectory: { _ in }, closeWorkspace: { _ in })
} else {
Text("Workspaces")
}
+45
View File
@@ -48,6 +48,26 @@ struct MobileWorkspaceTests {
#expect(first.destinationURL != second.destinationURL)
}
@Test func renamingUpdatesRestorationAndActivityStateWithoutChangingIdentity() throws {
let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Production", kind: .ssh)
let workspace = MobileWorkspace(descriptor: descriptor, hosts: HostLibraryModel())
var activityTitle: String?
workspace.didChangeState = { activityTitle = $0.title }
#expect(!workspace.rename(to: " "))
#expect(workspace.rename(to: " Release Shell "))
#expect(workspace.id == descriptor.id)
#expect(workspace.descriptor.title == "Release Shell")
#expect(workspace.activitySession.title == "Release Shell")
#expect(activityTitle == "Release Shell")
#expect(!workspace.rename(to: "Release Shell"))
let data = try JSONEncoder().encode(workspace.descriptor)
let restored = try JSONDecoder().decode(MobileWorkspaceDescriptor.self, from: data)
#expect(restored.title == "Release Shell")
#expect(restored.id == descriptor.id)
}
@Test func closeAllDisconnectsAndRemovesEveryWorkspaceAndIsRepeatable() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
@@ -123,6 +143,31 @@ struct MobileTerminalShiftStateTests {
@Suite @MainActor
struct MobileWorkspaceConnectionRecoveryTests {
@Test func sameDirectorySFTPStartupUsesTheOverrideAndOffersSSHOnlyWhileConnected() async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let host = try configureConnectionFixture(in: app)
let id = try #require(app.open(hostID: host.id, kind: .sftp, initialPath: "/srv/releases/"))
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.close(id) }
#expect(workspace.sameDirectoryLaunch == nil)
#expect(await eventually { workspace.connected })
#expect(workspace.sftpSession.path == "/srv/releases")
#expect(
workspace.sameDirectoryLaunch
== MobileWorkspaceDirectoryLaunch(hostID: host.id, kind: .ssh, path: "/srv/releases")
)
#expect(app.renameWorkspace(id, to: " Release Files "))
#expect(workspace.descriptor.title == "Release Files")
workspace.disconnect()
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func backgroundExpirationResumesEveryAffectedWorkspaceOnForeground() async throws {
let background = MobileTestBackgroundTaskManager()
let connections = MobileSFTPConnectionBox(
@@ -162,6 +162,72 @@ struct MobileCommandGeneratorTests {
#expect(connections.writes.isEmpty)
}
@Test func sameDirectorySFTPLaunchRequiresLiveShellIntegrationAndWorkingDirectory() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
hosts: library,
connectionServices: connections.services
)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/releases/")
#expect(workspace.sameDirectoryLaunch == nil)
await connections.sendShellEvents([
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(
await eventually {
workspace.sameDirectoryLaunch
== MobileWorkspaceDirectoryLaunch(hostID: host.id, kind: .sftp, path: "/srv/releases")
})
workspace.receiveWorkingDirectory(nil)
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func directoryStartupCommandRunsOnceWithoutEnteringHistory() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let command = try #require(RemoteShellCommand.changeDirectory(to: "/srv/it's live"))
let id = try #require(app.open(hostID: host.id, kind: .ssh, initialPath: "/srv/it's live"))
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.close(id) }
#expect(await eventually { workspace.connected })
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.commandStarted(command),
.commandFinished(0, duration: 0.01),
.commandStarted("pwd"),
.commandFinished(0, duration: 0.02),
])
#expect(await eventually { app.commandHistory.entries(for: host.id).count == 1 })
#expect(app.commandHistory.entries(for: host.id).first?.command == "pwd")
#expect(workspace.pendingInitialCommand == nil)
}
@Test func newTerminalInputCancelsAnInFlightGeneration() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
@@ -261,6 +327,8 @@ struct MobileCommandGeneratorTests {
#expect(await eventually { connections.shellIntegrationPreferences == [false] })
#expect(!workspace.canGenerateFromCurrentDraft)
workspace.receiveWorkingDirectory("/srv/app")
#expect(workspace.sameDirectoryLaunch == nil)
}
@Test func unsupportedShellRemainsUnavailableAfterOrderedPromptEvents() async throws {
@@ -228,6 +228,27 @@ struct MobileNavigationParityTests {
#expect(segments[1].contains("Button(\"New Folder\", systemImage: \"folder.badge.plus\")"))
}
@Test func workspaceMenusLeadWithConditionalSameDirectoryActionsAndExposeRename() throws {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
let workspace = try String(
contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"), encoding: .utf8)
let rootView = try String(
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(workspace.components(separatedBy: "Menu {\n sameDirectoryMenuAction").count == 3)
#expect(workspace.contains("if let launch = workspace.sameDirectoryLaunch"))
#expect(workspace.contains("Button(\"Open with SSH\", systemImage: \"terminal\")"))
#expect(workspace.contains("Button(\"Open with SFTP\", systemImage: \"folder\")"))
#expect(
workspace.components(
separatedBy: "Button(\"Rename\", systemImage: \"pencil\") { renameWorkspace(workspace) }"
).count == 3)
#expect(rootView.contains("Button(\"Rename\", systemImage: \"pencil\")"))
#expect(rootView.contains("MobileWorkspaceRenameView(workspace: workspace)"))
}
@Test func keyGenerationMatchesDesktopDefaultsAndRequiresMatchingPassphrases() throws {
let app = try MobileAppModel(inMemory: true)
let editor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
+1 -10
View File
@@ -812,7 +812,7 @@ final class WindowModel {
let pane = Pane.sshSession(
host: host,
usernameHint: usernameHint,
initialCommand: initialPath.flatMap(Self.changeDirectoryCommand),
initialCommand: initialPath.flatMap(RemoteShellCommand.changeDirectory),
recordsInitialCommandInHistory: initialPath == nil
)
numberPane(pane)
@@ -926,15 +926,6 @@ final class WindowModel {
open(Workspace(pane: .serialSession(configuration: configuration, service: service)))
}
/// A one-shot directory change for a shell opened from an SFTP path.
/// Single-quoting keeps spaces and shell punctuation inert; embedded
/// apostrophes are closed, emitted literally, and reopened.
nonisolated static func changeDirectoryCommand(for path: String) -> String? {
guard !path.isEmpty else { return nil }
let quoted = path.replacingOccurrences(of: "'", with: "'\"'\"'")
return "cd '\(quoted)'"
}
/// Opens the host editor, reusing the workspace that already holds one for the
/// same host — and "no host" counts as a host here, so New Host twice means
/// "show me the New Host form" rather than handing back a second empty copy
-9
View File
@@ -1139,15 +1139,6 @@ struct WindowStateTests {
#expect(sftpWorkspace.focusedPane.sshSession?.hostID == host.id)
}
@Test
func sshDirectoryCommandQuotesRemotePaths() {
#expect(
WindowModel.changeDirectoryCommand(for: "/srv/it's live")
== "cd '/srv/it'\"'\"'s live'"
)
#expect(WindowModel.changeDirectoryCommand(for: "") == nil)
}
@Test
func sftpWithoutAnOverrideKeepsTheHostsConfiguredInitialPath() {
var host = Host(name: "Files", host: "files.example")