feat(ios): close windows with cmd-w without confirmation
This commit is contained in:
1 parent
aa5a92c352
commit
315054763f
7 files changed
+164
-3
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user