feat(ios): close windows with cmd-w without confirmation

This commit is contained in:
Kaede Akino committed 2026-10-03 04:34:09 +08:00
1 parent aa5a92c352
commit 315054763f
7 files changed
+164 -3

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ struct MobileAppDependencies {
var liveActivities: MobileLiveActivityController? = nil
var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void =
MobileWorkspaceSceneRouting.activate
var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void =
MobileWorkspaceSceneRouting.close
var makeForwardings: (HostLibraryModel) -> PortForwardingController = { PortForwardingController(hosts: $0) }
static func live() -> MobileAppDependencies {
+2
View File
@@ -159,6 +159,7 @@ final class MobileAppModel {
private var recoveredWorkspaceIDs: [String: Set<UUID>] = [:]
private var discardedSessionIDs: Set<String> = []
var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void
var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void
let outbox: OutboxRunner
private var sceneSessionIDs: [UUID: String] = [:]
@@ -193,6 +194,7 @@ final class MobileAppModel {
try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration)
}
activateWorkspaceSession = dependencies.activateWorkspaceSession
closeWorkspaceSession = dependencies.closeWorkspaceSession
self.background = background
self.workspaceConnectionServices = workspaceConnectionServices
workspaceStore =
+7 -3
View File
@@ -84,11 +84,15 @@ struct MobileCommands: Commands {
}
CommandGroup(replacing: .saveItem) {
Button(AppStrings.Workspace.closeWorkspace.swiftUIKey, systemImage: "xmark") {
scene?.closeActiveWorkspace()
Button(
scene?.canCloseWorkspace == true
? AppStrings.Workspace.closeWorkspace.swiftUIKey : AppStrings.Workspace.closeWindow.swiftUIKey,
systemImage: "xmark"
) {
scene?.closeSelectionOrWindow()
}
.keyboardShortcut(MobileShortcut.closeWorkspace)
.disabled(scene?.canCloseWorkspace != true)
.disabled(scene == nil)
}
CommandMenu(AppStrings.Navigation.workspaces.swiftUIKey) {
+16
View File
@@ -315,6 +315,22 @@ final class MobileSceneModel {
closeWorkspace(activeWorkspace.id)
}
func closeSelectionOrWindow() {
guard !workspaceSession.isDiscarded else { return }
if activeWorkspace != nil {
closeActiveWorkspace()
} else {
// UIKit's discard callback owns cleanup; a rejected request must preserve this session.
let logger = app.logging.makeLogger(label: "termind.window")
app.closeWorkspaceSession(workspaceSession.id) { error in
let error = error as NSError
logger.warning(
"Window close request failed",
metadata: ["domain": .string(error.domain), "code": .stringConvertible(error.code)])
}
}
}
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
guard workspaceSession.orderedWorkspaceItems.contains(where: { $0.id == id }) else { return }
workspaceSession.closeOtherWorkspaces(than: id)
@@ -4,6 +4,10 @@ import UIKit
/// Links keep their existing workspace UUID. Window ownership is resolved locally by the app.
@MainActor
enum MobileWorkspaceSceneRouting {
nonisolated enum CloseError: Error {
case sessionNotFound
}
static let activityType = "com.akinokaede.termind.open-workspace"
private static let workspaceIDKey = "workspaceID"
@@ -38,4 +42,15 @@ enum MobileWorkspaceSceneRouting {
MainActor.assumeIsolated { unavailable() }
}
}
static func close(sessionID: String, failed: @escaping @MainActor (any Error) -> Void) {
guard let session = UIApplication.shared.openSessions.first(where: { $0.persistentIdentifier == sessionID })
else {
failed(CloseError.sessionNotFound)
return
}
UIApplication.shared.requestSceneSessionDestruction(session, options: nil) { error in
MainActor.assumeIsolated { failed(error) }
}
}
}
+48
View File
@@ -225,6 +225,54 @@ struct MobileShortcutTests {
#expect(scene.workspaceID == third)
}
@Test func closeCommandClosesTheLastWorkspaceBeforeRequestingWindowClosure() throws {
var requestedSessions: [String] = []
var dependencies = MobileAppDependencies.testing()
dependencies.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
let app = try MobileAppModel(dependencies: dependencies)
let host = Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
let scene = MobileSceneModel(app: app, sessionID: "owner")
let other = MobileSceneModel(app: app, sessionID: "other")
defer { app.discardWorkspaceSessions(["owner", "other"]) }
let first = try #require(scene.workspaceSession.open(hostID: host.id, kind: .ssh))
let second = try #require(other.workspaceSession.open(hostID: host.id, kind: .sftp))
scene.selectWorkspace(first)
scene.activatePendingWorkspace()
other.selectWorkspace(second)
other.activatePendingWorkspace()
scene.closeSelectionOrWindow()
#expect(scene.workspaceSession.orderedWorkspaceItems.isEmpty)
#expect(scene.workspaceID == nil)
#expect(requestedSessions.isEmpty)
#expect(other.activeWorkspace?.id == second)
scene.closeSelectionOrWindow()
#expect(requestedSessions == ["owner"])
#expect(other.activeWorkspace?.id == second)
}
@Test(arguments: MobileDestination.allCases)
func closeCommandWithoutSelectionRequestsOnlyItsOwnWindow(destination: MobileDestination) throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "owner")
let other = MobileSceneModel(app: app, sessionID: "other")
defer { app.discardWorkspaceSessions(["owner", "other"]) }
var requestedSessions: [String] = []
app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
scene.selectedDestination = destination
scene.closeSelectionOrWindow()
#expect(requestedSessions == [scene.workspaceSession.id])
#expect(!other.workspaceSession.isDiscarded)
#expect(app.error == nil)
#expect(!scene.isWorkspaceUnavailable)
}
@Test func closeCommandOnlyClosesAWorkspaceVisibleInThisScene() throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "Test", host: "example.invalid")
@@ -638,6 +638,80 @@ struct MobileWorkspaceSessionTests {
#expect(first.isWorkspaceUnavailable)
}
@Test func windowCloseWaitsForNativeDiscardBeforeRemovingItsWorkspaces() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let first = MobileSceneModel(app: app, sessionID: "a")
let second = MobileSceneModel(app: app, sessionID: "b")
first.open(hostID: fixture.host.id, kind: .ssh)
second.open(hostID: fixture.host.id, kind: .sftp)
let firstID = try #require(first.workspaceSession.workspaces.first?.id)
let secondID = try #require(second.workspaceSession.workspaces.first?.id)
var requestedSessions: [String] = []
app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) }
first.openSettings()
first.closeSelectionOrWindow()
#expect(requestedSessions == ["a"])
#expect(first.workspaceSession.workspaces.map(\.id) == [firstID])
#expect(try fixture.store.load().sessions["a"]?.map(\.id) == [firstID])
let delegate = MobileAppDelegate()
delegate.app = app
delegate.discardSessions(["a"])
#expect(first.workspaceSession.isDiscarded)
#expect(first.workspaceSession.orderedWorkspaceItems.isEmpty)
#expect(try fixture.store.load().sessions["a"] == nil)
#expect(second.workspaceSession.workspaces.map(\.id) == [secondID])
#expect(try fixture.store.load().sessions["b"]?.map(\.id) == [secondID])
#expect(app.error == nil)
first.closeSelectionOrWindow()
#expect(requestedSessions == ["a"])
}
@Test func rejectedWindowCloseKeepsWorkspacesAndSavedRecordsWithoutAnAlert() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let scene = MobileSceneModel(app: app, sessionID: "a")
scene.open(hostID: fixture.host.id, kind: .ssh)
let workspace = try #require(scene.workspaceSession.workspaces.first)
var reportFailure: (@MainActor (any Error) -> Void)?
app.closeWorkspaceSession = { _, failed in reportFailure = failed }
scene.openSettings()
scene.closeSelectionOrWindow()
let failed = try #require(reportFailure)
failed(NSError(domain: "WindowCloseTest", code: 1))
#expect(scene.workspaceSession.workspaces.first === workspace)
#expect(!scene.workspaceSession.isDiscarded)
#expect(try fixture.store.load().sessions["a"]?.map(\.id) == [workspace.id])
#expect(scene.selectedDestination == .settings)
#expect(!scene.isWorkspaceUnavailable)
#expect(app.error == nil)
}
@Test func closingMissingNativeSessionReportsFailureWithoutDiscardingSavedWorkspaces() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let id = "missing-\(UUID())"
let scene = MobileSceneModel(app: app, sessionID: id)
scene.open(hostID: fixture.host.id, kind: .ssh)
let workspaceID = try #require(scene.workspaceSession.workspaces.first?.id)
var failure: (any Error)?
MobileWorkspaceSceneRouting.close(sessionID: id) { failure = $0 }
#expect(failure as? MobileWorkspaceSceneRouting.CloseError == .sessionNotFound)
#expect(scene.workspaceSession.workspaces.map(\.id) == [workspaceID])
#expect(try fixture.store.load().sessions[id]?.map(\.id) == [workspaceID])
#expect(app.error == nil)
}
@Test func missingNativeWindowReportsUnavailableWithoutCreatingAConnection() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }