From fcba94491129f5194e1deafb8348b60c05eccc71 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 09:01:07 +0800 Subject: [PATCH] Add TFI workspace rename and directory handoff --- FrameworkTests/RemoteShellCommandTests.swift | 14 +++ .../Shell/RemoteShellCommand.swift | 14 +++ Memory/ARCHITECTURE.md | 15 +++- TFI/App/MobileAppModel.swift | 56 ++++++++++-- TFI/App/MobileRootView.swift | 25 +++++- TFI/Features/Workspaces/MobileWorkspace.swift | 90 ++++++++++++++++--- .../MobileWorkspaceDescriptor.swift | 6 ++ .../MobileWorkspaceRenameView.swift | 29 ++++++ .../Workspaces/MobileWorkspaceView.swift | 21 +++++ TFITests/MobileAISettingsToolbarTests.swift | 6 +- TFITests/MobileAppTests.swift | 45 ++++++++++ ...MobileCommandGeneratorAndUploadTests.swift | 68 ++++++++++++++ TFITests/MobileNavigationParityTests.swift | 21 +++++ TFM/Features/Workspaces/WindowModel.swift | 11 +-- TFMTests/WindowStateTests.swift | 9 -- 15 files changed, 389 insertions(+), 41 deletions(-) create mode 100644 FrameworkTests/RemoteShellCommandTests.swift create mode 100644 Frameworks/TermindDomain/Shell/RemoteShellCommand.swift create mode 100644 TFI/Features/Workspaces/MobileWorkspaceRenameView.swift diff --git a/FrameworkTests/RemoteShellCommandTests.swift b/FrameworkTests/RemoteShellCommandTests.swift new file mode 100644 index 00000000..77ff902f --- /dev/null +++ b/FrameworkTests/RemoteShellCommandTests.swift @@ -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) + } +} diff --git a/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift new file mode 100644 index 00000000..d0204ef6 --- /dev/null +++ b/Frameworks/TermindDomain/Shell/RemoteShellCommand.swift @@ -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)'" + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index e46a3ab6..2980a7b1 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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 diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 74b016bb..b1e8acb6 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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]) { diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 85607185..14e7b581 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -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 } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 21482d12..035e2f84 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 diff --git a/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift b/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift index bbd03af8..60338529 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift @@ -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 +} diff --git a/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift b/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift new file mode 100644 index 00000000..16b154d0 --- /dev/null +++ b/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift @@ -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) + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 775ee82c..6df98864 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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 } diff --git a/TFITests/MobileAISettingsToolbarTests.swift b/TFITests/MobileAISettingsToolbarTests.swift index 7e979d9c..ea87cb9e 100644 --- a/TFITests/MobileAISettingsToolbarTests.swift +++ b/TFITests/MobileAISettingsToolbarTests.swift @@ -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") } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 52863c5c..60f2a55a 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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( diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 3b4e53a4..fe95674e 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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 { diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 37d537e6..df19a600 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -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) diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index a8cecf0d..b9de60bc 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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 diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index d4a3d296..a194228d 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -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")