Add more oprations for workspaces in TFI

This commit is contained in:
Kaede Akino committed 2026-09-22 00:07:34 +08:00
1 parent f0aeb33a8a
commit c50f3e3ad9
9 files changed
+245 -24

No files matched your search

+21
View File
@@ -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
+8
View File
@@ -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
+3 -1
View File
@@ -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")
}
+39
View File
@@ -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")
+6 -2
View File
@@ -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 })
+121
View File
@@ -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)
}
}
}
}