230 lines
9.4 KiB
Swift
230 lines
9.4 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite(.serialized) @MainActor
|
|
struct WorkspaceCloseConfirmationTests {
|
|
@Test func windowCloseFlushesThemeEditsWithoutADiscardPrompt() throws {
|
|
let settings = TerminalSettingsModel()
|
|
let model = WindowModel()
|
|
let theme = TerminalCustomTheme(name: "New", appearance: .both, baseThemeName: "Termind Dark")
|
|
model.openSettings(route: .terminalCustomThemes)
|
|
model.beginEditingTerminalTheme(theme, isNew: true, in: settings)
|
|
let draft = try #require(model.terminalThemeDraft)
|
|
draft.value.configuration = "background = 123456"
|
|
let native = makeWindow()
|
|
defer { native.close() }
|
|
var prompted = false
|
|
let delegate = WorkspaceWindowCloseDelegate(
|
|
model: model,
|
|
confirmDiscardTheme: {
|
|
prompted = true
|
|
return false
|
|
})
|
|
#expect(delegate.windowShouldClose(native))
|
|
#expect(!prompted)
|
|
#expect(settings.themeLibrary.themes.first?.configuration == "background = 123456")
|
|
#expect(model.terminalThemeDraft == nil)
|
|
}
|
|
|
|
@Test func quitFlushesThemeEditsAcrossRegisteredWindows() throws {
|
|
let settings = TerminalSettingsModel()
|
|
let app = AppModel(hosts: HostLibraryModel(), terminalSettings: settings)
|
|
defer { app.shutdownForTermination() }
|
|
let windows = [WindowModel(), WindowModel()]
|
|
for (index, window) in windows.enumerated() {
|
|
app.registerWindow(window)
|
|
let theme = TerminalCustomTheme(name: "", appearance: .both, baseThemeName: "Termind Dark")
|
|
window.openSettings(route: .terminalCustomThemes)
|
|
window.beginEditingTerminalTheme(theme, isNew: true, in: settings)
|
|
window.terminalThemeDraft?.value.name = "Theme \(index)"
|
|
}
|
|
#expect(app.hasUnsavedTerminalThemes)
|
|
app.saveTerminalThemeChanges()
|
|
#expect(!app.hasUnsavedTerminalThemes)
|
|
#expect(settings.themeLibrary.themes.count == 2)
|
|
}
|
|
|
|
@Test func emptyWindowStillProtectsAnUnsavedThemeDraft() {
|
|
let model = WindowModel()
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
app.registerWindow(model)
|
|
let draft = TerminalCustomThemeDraft(
|
|
.init(name: "Theme", appearance: .both, baseThemeName: "Termind Dark"), isNew: true)
|
|
model.terminalThemeDraft = draft
|
|
draft.value.configuration = "background = 123456"
|
|
let native = makeWindow()
|
|
var allowsDiscard = false
|
|
let delegate = WorkspaceWindowCloseDelegate(
|
|
model: model, confirmDiscardTheme: { allowsDiscard })
|
|
defer {
|
|
native.close()
|
|
app.shutdownForTermination()
|
|
}
|
|
#expect(app.hasUnsavedTerminalThemes)
|
|
#expect(!delegate.windowShouldClose(native))
|
|
#expect(model.terminalThemeDraft === draft)
|
|
allowsDiscard = true
|
|
#expect(delegate.windowShouldClose(native))
|
|
#expect(model.terminalThemeDraft == nil)
|
|
#expect(!app.hasUnsavedTerminalThemes)
|
|
}
|
|
|
|
@Test
|
|
func quitChecksWorkspacesAcrossAllRegisteredWindows() {
|
|
let app = AppModel(hosts: HostLibraryModel())
|
|
let empty = WindowModel()
|
|
let populated = WindowModel(workspaces: [.sshSession(host: Host(name: "Server", host: "server.example"))])
|
|
app.registerWindow(empty)
|
|
#expect(!app.hasOpenWorkspaces)
|
|
app.registerWindow(populated)
|
|
#expect(app.hasOpenWorkspaces)
|
|
populated.closeForWindow()
|
|
#expect(!app.hasOpenWorkspaces)
|
|
let other = WindowModel(workspaces: [.sshSession(host: Host(name: "Other", host: "other.example"))])
|
|
app.registerWindow(other)
|
|
#expect(app.hasOpenWorkspaces)
|
|
app.unregisterWindow(other)
|
|
#expect(!app.hasOpenWorkspaces)
|
|
other.closeForWindow()
|
|
}
|
|
|
|
@Test
|
|
func populatedWindowClosesWithoutAPromptAndStillRespectsTheNativeDelegate() {
|
|
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
|
|
let model = WindowModel(workspaces: [workspace])
|
|
let native = makeWindow()
|
|
let original = WindowDelegate()
|
|
native.delegate = original
|
|
let proxy = WorkspaceWindowCloseDelegate(model: model) {
|
|
Issue.record("Live connections do not need a close confirmation.")
|
|
return false
|
|
}
|
|
proxy.attach(to: native)
|
|
defer {
|
|
proxy.attach(to: nil)
|
|
native.close()
|
|
model.closeForWindow()
|
|
}
|
|
|
|
#expect(native.delegate?.windowShouldClose?(native) == true)
|
|
#expect(!model.isClosed)
|
|
#expect(model.workspaces.first === workspace)
|
|
original.allowsClose = false
|
|
#expect(native.delegate?.windowShouldClose?(native) == false)
|
|
original.allowsClose = true
|
|
#expect(native.delegate?.windowShouldClose?(native) == true)
|
|
#expect(original.requests == 3)
|
|
|
|
let selector = #selector(WindowDelegate.windowDidResize(_:))
|
|
#expect(proxy.responds(to: selector))
|
|
#expect(proxy.forwardingTarget(for: selector) as? WindowDelegate === original)
|
|
proxy.attach(to: nil)
|
|
#expect(native.delegate === original)
|
|
}
|
|
|
|
@Test
|
|
func closingWindowWithManagedSessionDisconnectsWithoutEndingItsRecoveryIdentity() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let hosts = HostLibraryModel(hosts: [host])
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let session = try #require(workspace.focusedPane.sshSession)
|
|
let reference = RemoteSessionReference(
|
|
backend: .tmux, session: .init(id: "session", name: "Managed", identity: Data([1])),
|
|
hostID: host.id, endpoint: "server.example:22", username: "user", ownershipToken: UUID())
|
|
session.sessionPersistence.attachment = reference
|
|
let model = WindowModel(workspaces: [workspace])
|
|
var saved: ClosedWorkspaceRecord?
|
|
model.rememberClosedWindow = { workspaces, _ in
|
|
saved = workspaces.first.map { ClosedWorkspaceRecord($0, hosts: hosts) }
|
|
}
|
|
let native = makeWindow()
|
|
let proxy = WorkspaceWindowCloseDelegate(model: model) {
|
|
Issue.record("A managed session does not need a close confirmation.")
|
|
return false
|
|
}
|
|
proxy.attach(to: native)
|
|
defer {
|
|
proxy.attach(to: nil)
|
|
native.close()
|
|
model.closeForWindow()
|
|
}
|
|
|
|
#expect(native.delegate?.windowShouldClose?(native) == true)
|
|
#expect(model.persistentSessionCloser?.isClosing != true)
|
|
model.closeForWindow()
|
|
#expect(model.isClosed)
|
|
#expect(model.workspaces.isEmpty)
|
|
#expect(session.status == .disconnected)
|
|
#expect(session.sessionPersistence.attachment == reference)
|
|
let restored = try #require(saved?.restore(in: hosts))
|
|
#expect(restored.focusedPane.sshSession?.sessionPersistence.attachment == reference)
|
|
}
|
|
|
|
@Test func closeWithoutACleanupOwnerKeepsItsRecoveryReference() throws {
|
|
let host = Host(name: "Server", host: "server.example")
|
|
let hosts = HostLibraryModel(hosts: [host])
|
|
let workspace = Workspace.sshSession(host: host)
|
|
let session = try #require(workspace.focusedPane.sshSession)
|
|
let reference = RemoteSessionReference(
|
|
backend: .zellij, session: .init(id: "session", name: "Managed", identity: Data([1])),
|
|
hostID: host.id, endpoint: "server.example:22", username: "user", ownershipToken: UUID())
|
|
session.sessionPersistence.attachment = reference
|
|
let model = WindowModel(workspaces: [workspace])
|
|
var saved: ClosedWorkspaceRecord?
|
|
model.rememberClosedWorkspace = { saved = ClosedWorkspaceRecord($0, hosts: hosts) }
|
|
defer { model.closeForWindow() }
|
|
|
|
// An unregistered window has no app-owned remote cleanup coordinator.
|
|
model.closeSelected()
|
|
#expect(model.workspaces.isEmpty)
|
|
#expect(session.sessionPersistence.attachment == reference)
|
|
#expect(session.status == .disconnected)
|
|
let restored = try #require(saved?.restore(in: hosts))
|
|
#expect(restored.focusedPane.sshSession?.sessionPersistence.attachment == reference)
|
|
}
|
|
|
|
@Test
|
|
func emptyWindowClosesWithoutConfirmation() {
|
|
let model = WindowModel()
|
|
let native = makeWindow()
|
|
let proxy = WorkspaceWindowCloseDelegate(model: model) {
|
|
Issue.record("An empty window should not require confirmation.")
|
|
return false
|
|
}
|
|
proxy.attach(to: native)
|
|
defer {
|
|
proxy.attach(to: nil)
|
|
native.close()
|
|
}
|
|
#expect(native.delegate?.windowShouldClose?(native) == true)
|
|
}
|
|
|
|
private func makeWindow() -> NSWindow {
|
|
let window = NSWindow(
|
|
contentRect: CGRect(x: 0, y: 0, width: 800, height: 600),
|
|
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
return window
|
|
}
|
|
|
|
private final class WindowDelegate: NSObject, NSWindowDelegate {
|
|
var allowsClose = true
|
|
var requests = 0
|
|
|
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
requests += 1
|
|
return allowsClose
|
|
}
|
|
|
|
func windowDidResize(_ notification: Notification) {}
|
|
}
|
|
}
|