Add TFI workspace rename and directory handoff
This commit is contained in:
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
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user