Add more oprations for workspaces in TFI
This commit is contained in:
1 parent
f0aeb33a8a
commit
c50f3e3ad9
9 files changed
+245
-24
No files matched your search
@@ -380,6 +380,27 @@ final class MobileAppModel {
|
||||
liveActivities.update(workspaceActivitySessions)
|
||||
}
|
||||
|
||||
var closableWorkspaceCount: Int {
|
||||
Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)).count
|
||||
}
|
||||
|
||||
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
|
||||
let knownIDs = Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id))
|
||||
guard knownIDs.contains(id), knownIDs.count > 1 else { return }
|
||||
|
||||
let closing = workspaces.filter { $0.id != id }
|
||||
workspaces.removeAll { $0.id != id }
|
||||
savedWorkspaceDescriptors.removeAll { $0.id != id }
|
||||
saveWorkspaces()
|
||||
workspaceActivitySessions.removeAll { $0.id != id }
|
||||
for workspace in closing {
|
||||
workspace.didChangeState = nil
|
||||
workspace.didRequestClose = nil
|
||||
workspace.disconnect()
|
||||
}
|
||||
liveActivities.update(workspaceActivitySessions)
|
||||
}
|
||||
|
||||
func closeAll() {
|
||||
guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return }
|
||||
let closing = workspaces
|
||||
|
||||
@@ -45,6 +45,7 @@ final class MobileSceneModel {
|
||||
|
||||
var hasWorkspaces: Bool { !app.workspaces.isEmpty }
|
||||
var canCloseAllWorkspaces: Bool { !app.savedWorkspaceDescriptors.isEmpty || !app.workspaces.isEmpty }
|
||||
var canCloseMultipleWorkspaces: Bool { app.closableWorkspaceCount > 1 }
|
||||
var workspaceCount: Int { app.workspaces.count }
|
||||
var canCloseWorkspace: Bool { activeWorkspace != nil }
|
||||
var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind == .ssh }
|
||||
@@ -234,6 +235,13 @@ final class MobileSceneModel {
|
||||
closeWorkspace(activeWorkspace.id)
|
||||
}
|
||||
|
||||
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
|
||||
app.closeOtherWorkspaces(than: id)
|
||||
recentWorkspaceIDs.removeAll { $0 != id }
|
||||
if pendingWorkspaceID != id { pendingWorkspaceID = nil }
|
||||
if workspaceID != id { workspaceID = nil }
|
||||
}
|
||||
|
||||
func closeAllWorkspaces() {
|
||||
app.closeAll()
|
||||
recentWorkspaceIDs.removeAll()
|
||||
|
||||
@@ -19,7 +19,7 @@ struct MobileWorkspaceToolbar: ToolbarContent {
|
||||
}
|
||||
.mobileProminentAction()
|
||||
MobileToolbarOverflow {
|
||||
Button("Close All Workspaces", systemImage: "rectangle.stack.badge.minus") {
|
||||
Button("Close All Workspaces", systemImage: "xmark") {
|
||||
closeAll()
|
||||
}
|
||||
.disabled(!hasWorkspaces)
|
||||
|
||||
@@ -101,8 +101,11 @@ struct MobileWorkspaceListView: View {
|
||||
workspace: workspace, workspaces: app.workspaces,
|
||||
selectWorkspace: scene.selectWorkspace,
|
||||
renameWorkspace: scene.beginWorkspaceRename,
|
||||
canCloseMultipleWorkspaces: scene.canCloseMultipleWorkspaces,
|
||||
openSameDirectory: scene.openSameDirectory,
|
||||
closeWorkspace: scene.closeWorkspace
|
||||
closeWorkspace: scene.closeWorkspace,
|
||||
closeOtherWorkspaces: scene.closeOtherWorkspaces,
|
||||
closeAllWorkspaces: scene.closeAllWorkspaces
|
||||
)
|
||||
// The title menu replaces this pushed detail in place. A new identity tears down
|
||||
// SFTP's native search state before an SSH detail is mounted.
|
||||
@@ -169,13 +172,13 @@ struct MobileWorkspaceListView: View {
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Rename", systemImage: "pencil") {
|
||||
scene.beginWorkspaceRename(workspace)
|
||||
}
|
||||
Divider()
|
||||
Button("Close", systemImage: "xmark", role: .destructive) {
|
||||
scene.closeWorkspace(workspace.id)
|
||||
}
|
||||
MobileWorkspaceLifecycleMenuActions(
|
||||
canCloseMultipleWorkspaces: scene.canCloseMultipleWorkspaces,
|
||||
rename: { scene.beginWorkspaceRename(workspace) },
|
||||
close: { scene.closeWorkspace(workspace.id) },
|
||||
closeOthers: { scene.closeOtherWorkspaces(than: workspace.id) },
|
||||
closeAll: scene.closeAllWorkspaces
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ struct MobileWorkspaceView: View {
|
||||
let workspaces: [MobileWorkspace]
|
||||
let selectWorkspace: (UUID) -> Void
|
||||
let renameWorkspace: (MobileWorkspace) -> Void
|
||||
let canCloseMultipleWorkspaces: Bool
|
||||
let openSameDirectory: (MobileWorkspaceDirectoryLaunch) -> Void
|
||||
let closeWorkspace: (UUID) -> Void
|
||||
let closeOtherWorkspaces: (UUID) -> Void
|
||||
let closeAllWorkspaces: () -> Void
|
||||
@State private var terminalSheet: TerminalSheet?
|
||||
@State private var importing = false
|
||||
@State private var directoryName = ""
|
||||
@@ -178,10 +181,7 @@ struct MobileWorkspaceView: View {
|
||||
}
|
||||
Divider()
|
||||
sameDirectoryMenuAction
|
||||
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
|
||||
Button("Close", systemImage: "xmark", role: .destructive) {
|
||||
closeWorkspace(workspace.id)
|
||||
}
|
||||
workspaceLifecycleActions
|
||||
}
|
||||
}
|
||||
if workspace.descriptor.kind == .telnet {
|
||||
@@ -195,10 +195,7 @@ struct MobileWorkspaceView: View {
|
||||
workspace.find.presentAndNavigate(.next)
|
||||
}
|
||||
Divider()
|
||||
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
|
||||
Button("Close", systemImage: "xmark", role: .destructive) {
|
||||
closeWorkspace(workspace.id)
|
||||
}
|
||||
workspaceLifecycleActions
|
||||
}
|
||||
}
|
||||
if isSFTP {
|
||||
@@ -237,14 +234,22 @@ struct MobileWorkspaceView: View {
|
||||
.disabled(!workspace.connected || workspace.sftpChannel.isRefreshing)
|
||||
Divider()
|
||||
sameDirectoryMenuAction
|
||||
Button("Rename", systemImage: "pencil") { renameWorkspace(workspace) }
|
||||
Button("Close", systemImage: "xmark", role: .destructive) {
|
||||
closeWorkspace(workspace.id)
|
||||
}
|
||||
workspaceLifecycleActions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceLifecycleActions: some View {
|
||||
MobileWorkspaceLifecycleMenuActions(
|
||||
canCloseMultipleWorkspaces: canCloseMultipleWorkspaces,
|
||||
rename: { renameWorkspace(workspace) },
|
||||
close: { closeWorkspace(workspace.id) },
|
||||
closeOthers: { closeOtherWorkspaces(workspace.id) },
|
||||
closeAll: closeAllWorkspaces
|
||||
)
|
||||
}
|
||||
|
||||
private var workspaceSheets: some View {
|
||||
workspaceChrome
|
||||
.sheet(item: $terminalSheet) { sheet in
|
||||
@@ -737,6 +742,24 @@ struct MobileWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileWorkspaceLifecycleMenuActions: View {
|
||||
let canCloseMultipleWorkspaces: Bool
|
||||
let rename: () -> Void
|
||||
let close: () -> Void
|
||||
let closeOthers: () -> Void
|
||||
let closeAll: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button("Rename", systemImage: "pencil", action: rename)
|
||||
Divider()
|
||||
Button("Close", systemImage: "xmark", role: .destructive, action: close)
|
||||
if canCloseMultipleWorkspaces {
|
||||
Button("Close Other Workspaces", role: .destructive, action: closeOthers)
|
||||
Button("Close All Workspaces", systemImage: "xmark", role: .destructive, action: closeAll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileCommandGeneratorOverlay: View {
|
||||
let state: CommandGeneratorState
|
||||
|
||||
|
||||
@@ -79,7 +79,9 @@ private struct WorkspaceTabBarHarness: View {
|
||||
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
|
||||
workspace: workspace, workspaces: [workspace],
|
||||
selectWorkspace: { _ in }, renameWorkspace: { _ in },
|
||||
openSameDirectory: { _ in }, closeWorkspace: { _ in })
|
||||
canCloseMultipleWorkspaces: false,
|
||||
openSameDirectory: { _ in }, closeWorkspace: { _ in },
|
||||
closeOtherWorkspaces: { _ in }, closeAllWorkspaces: {})
|
||||
} else {
|
||||
Text("Workspaces")
|
||||
}
|
||||
|
||||
@@ -180,6 +180,45 @@ struct MobileWorkspaceTests {
|
||||
#expect(app.workspaces.isEmpty)
|
||||
}
|
||||
|
||||
@Test func closeOtherWorkspacesKeepsTheTargetAndRemovesOpenedAndRecentWorkspaces() throws {
|
||||
let suite = "termind.close-other-workspaces.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
|
||||
let seed = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
||||
seed.library.upsert(host)
|
||||
let descriptors = [
|
||||
MobileWorkspaceDescriptor(hostID: host.id, title: "Keep", kind: .ssh),
|
||||
MobileWorkspaceDescriptor(hostID: host.id, title: "Opened", kind: .sftp),
|
||||
MobileWorkspaceDescriptor(hostID: host.id, title: "Recent", kind: .ssh),
|
||||
]
|
||||
let store = MobileWorkspaceStore(defaults: defaults)
|
||||
try store.save(descriptors)
|
||||
var dependencies = MobileAppDependencies.testing(defaults: defaults)
|
||||
dependencies.container = seed.container
|
||||
dependencies.workspaceStore = store
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
let kept = try #require(app.openWorkspace(descriptors[0].id))
|
||||
let opened = try #require(app.openWorkspace(descriptors[1].id))
|
||||
|
||||
#expect(app.recentWorkspaces == [descriptors[2]])
|
||||
#expect(app.closableWorkspaceCount == 3)
|
||||
#expect(scene.canCloseMultipleWorkspaces)
|
||||
|
||||
scene.closeOtherWorkspaces(than: kept.id)
|
||||
|
||||
#expect(app.workspaces.map(\.id) == [kept.id])
|
||||
#expect(app.savedWorkspaceDescriptors == [kept.descriptor])
|
||||
#expect(app.recentWorkspaces.isEmpty)
|
||||
#expect(!opened.connected)
|
||||
#expect(opened.status == String(localized: "Disconnected"))
|
||||
#expect(try store.load() == [kept.descriptor])
|
||||
#expect(!scene.canCloseMultipleWorkspaces)
|
||||
app.closeAll()
|
||||
}
|
||||
|
||||
@Test func hostEditsPersistThroughTheSharedVaultStore() throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
|
||||
|
||||
@@ -178,7 +178,9 @@ struct MobileWorkspaceDropTests {
|
||||
MobileWorkspaceView(
|
||||
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
|
||||
workspace: workspace, workspaces: [workspace], selectWorkspace: { _ in },
|
||||
renameWorkspace: { _ in }, openSameDirectory: { _ in }, closeWorkspace: { _ in })
|
||||
renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false,
|
||||
openSameDirectory: { _ in }, closeWorkspace: { _ in },
|
||||
closeOtherWorkspaces: { _ in }, closeAllWorkspaces: {})
|
||||
}
|
||||
.environment(\.scenePhase, .active))
|
||||
window.rootViewController = controller
|
||||
@@ -236,7 +238,9 @@ struct MobileWorkspaceDropTests {
|
||||
MobileWorkspaceView(
|
||||
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
|
||||
workspace: workspace, workspaces: [workspace], selectWorkspace: { _ in },
|
||||
renameWorkspace: { _ in }, openSameDirectory: { _ in }, closeWorkspace: { _ in })
|
||||
renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false,
|
||||
openSameDirectory: { _ in }, closeWorkspace: { _ in },
|
||||
closeOtherWorkspaces: { _ in }, closeAllWorkspaces: {})
|
||||
}
|
||||
.environment(\.scenePhase, .active)
|
||||
.transaction { $0.disablesAnimations = true })
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MobileWorkspaceMenuTests {
|
||||
@Test(arguments: [false, true])
|
||||
func lifecycleActionsOnlyShowBatchClosingWhenMultipleWorkspacesCanClose(
|
||||
canCloseMultipleWorkspaces: Bool
|
||||
) async throws {
|
||||
try await withNavigation(
|
||||
LifecycleMenuHost(canCloseMultipleWorkspaces: canCloseMultipleWorkspaces)
|
||||
) { navigation in
|
||||
let button = try #require(toolbarButton(named: "Workspace Actions", in: navigation))
|
||||
let actions = try await resolvedMenuActions(#require(button.menu), in: navigation.view)
|
||||
let expected =
|
||||
canCloseMultipleWorkspaces
|
||||
? ["Rename", "Close", "Close Other Workspaces", "Close All Workspaces"]
|
||||
: ["Rename", "Close"]
|
||||
#expect(actions.map(\.title) == expected)
|
||||
#expect(!actions[0].attributes.contains(.destructive))
|
||||
#expect(actions.dropFirst().allSatisfy { $0.attributes.contains(.destructive) })
|
||||
}
|
||||
}
|
||||
|
||||
private func withNavigation<Content: View>(
|
||||
_ content: Content,
|
||||
check: (UINavigationController) async throws -> Void
|
||||
) async throws {
|
||||
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previousWindow = scene.keyWindow
|
||||
let window = UIWindow(windowScene: scene)
|
||||
let controller = UIHostingController(rootView: NavigationStack { content })
|
||||
window.rootViewController = controller
|
||||
window.windowLevel = .alert + 1
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previousWindow?.makeKey()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(700))
|
||||
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
|
||||
try await check(navigation)
|
||||
}
|
||||
|
||||
private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] {
|
||||
let button = UIButton(frame: CGRect(x: 100, y: 100, width: 44, height: 44))
|
||||
button.menu = menu
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
view.addSubview(button)
|
||||
defer { button.removeFromSuperview() }
|
||||
button.performPrimaryAction()
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let interaction = try #require(button.contextMenuInteraction)
|
||||
var actions: [UIAction] = []
|
||||
interaction.updateVisibleMenu { resolved in
|
||||
actions = menuActions(resolved)
|
||||
return resolved
|
||||
}
|
||||
interaction.dismissMenu()
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
return actions
|
||||
}
|
||||
|
||||
private func menuActions(_ menu: UIMenu) -> [UIAction] {
|
||||
menu.children.flatMap { element -> [UIAction] in
|
||||
if let action = element as? UIAction { return [action] }
|
||||
if let submenu = element as? UIMenu { return menuActions(submenu) }
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarButton(named name: String, in navigation: UINavigationController) -> UIBarButtonItem? {
|
||||
guard let item = navigation.topViewController?.navigationItem else { return nil }
|
||||
let buttons =
|
||||
item.leadingItemGroups.flatMap(\.barButtonItems)
|
||||
+ item.trailingItemGroups.flatMap(\.barButtonItems)
|
||||
+ (item.leftBarButtonItems ?? [])
|
||||
+ (item.rightBarButtonItems ?? [])
|
||||
return buttons.first {
|
||||
$0.accessibilityLabel == name || $0.title == name || $0.customView?.accessibilityLabel == name
|
||||
}
|
||||
}
|
||||
|
||||
private func descendant<T: UIViewController>(of type: T.Type, in controller: UIViewController) -> T? {
|
||||
var pending = [controller]
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
while let next = pending.popLast() {
|
||||
guard visited.insert(ObjectIdentifier(next)).inserted else { continue }
|
||||
if let result = next as? T { return result }
|
||||
pending.append(contentsOf: next.children)
|
||||
if let presented = next.presentedViewController { pending.append(presented) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct LifecycleMenuHost: View {
|
||||
let canCloseMultipleWorkspaces: Bool
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.navigationTitle("Workspace")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
MobileWorkspaceLifecycleMenuActions(
|
||||
canCloseMultipleWorkspaces: canCloseMultipleWorkspaces,
|
||||
rename: {}, close: {}, closeOthers: {}, closeAll: {})
|
||||
} label: {
|
||||
Label("Workspace Actions", systemImage: "ellipsis")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.menuOrder(.fixed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user