From c50f3e3ad923fcfc930b7d2dc94a8f5abe65b2cf Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 22 Sep 2026 00:07:34 +0800 Subject: [PATCH] Add more oprations for workspaces in TFI --- TFI/App/MobileAppModel.swift | 21 +++ TFI/App/MobileSceneModel.swift | 8 ++ .../HostChooser/MobileConnectionPicker.swift | 2 +- .../Workspaces/MobileWorkspaceListView.swift | 19 +-- .../Workspaces/MobileWorkspaceView.swift | 47 +++++-- TFITests/MobileAISettingsToolbarTests.swift | 4 +- TFITests/MobileAppTests.swift | 39 ++++++ TFITests/MobileDropTests.swift | 8 +- TFITests/MobileWorkspaceMenuTests.swift | 121 ++++++++++++++++++ 9 files changed, 245 insertions(+), 24 deletions(-) create mode 100644 TFITests/MobileWorkspaceMenuTests.swift diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 43163469..5b66474b 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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 diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 100f7540..9fb1c925 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -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() diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 2dc6ae75..1b060e2b 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -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) diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index cdd20441..f79c21b7 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -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 + ) } } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index d67bed75..a7c0ecc9 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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 diff --git a/TFITests/MobileAISettingsToolbarTests.swift b/TFITests/MobileAISettingsToolbarTests.swift index 98828274..d1362bf0 100644 --- a/TFITests/MobileAISettingsToolbarTests.swift +++ b/TFITests/MobileAISettingsToolbarTests.swift @@ -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") } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 2840973e..098bb794 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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") diff --git a/TFITests/MobileDropTests.swift b/TFITests/MobileDropTests.swift index d1822b2e..2f0ba02a 100644 --- a/TFITests/MobileDropTests.swift +++ b/TFITests/MobileDropTests.swift @@ -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 }) diff --git a/TFITests/MobileWorkspaceMenuTests.swift b/TFITests/MobileWorkspaceMenuTests.swift new file mode 100644 index 00000000..87642eaf --- /dev/null +++ b/TFITests/MobileWorkspaceMenuTests.swift @@ -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: 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(of type: T.Type, in controller: UIViewController) -> T? { + var pending = [controller] + var visited = Set() + 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) + } + } + } +}