Files
Termind/TFMTests/WindowStateTests.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

1718 lines
71 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite
@MainActor
struct WindowStateTests {
@Test func showHistoryLeavesLivePanesRunningAndAlwaysShowsConnections() throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let session = try #require(workspace.focusedPane.sshSession)
session.status = .connected
let window = WindowModel(workspaces: [workspace])
window.showsSidebar = false
window.showConnectionHistory()
window.showConnectionHistory()
#expect(window.selectedDestination == .vault)
#expect(window.showsSidebar && window.sidebarMode == .history)
#expect(window.workspaces.first === workspace)
#expect(session.status == .connected)
}
@Test func recentClosedReopensASpecificPaneWithoutConsumingTheNewestItem() throws {
let first = Workspace(pane: Pane(.empty))
let second = Workspace(pane: Pane(.empty))
let window = WindowModel(workspaces: [first, second])
let app = AppModel(hosts: HostLibraryModel())
app.registerWindow(window)
window.close(first.id)
window.close(second.id)
#expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id, first.id])
app.reopenClosedRecord(first.id, in: window)
#expect(window.selectedWorkspace === first)
#expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id])
app.reopenClosedRecord(first.id, in: window)
#expect(window.workspaces.count == 1)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
#expect(window.selectedWorkspace === second)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test(arguments: [WindowDestination.vault, .metrics, .settings, .assistant])
func showAssistantWorksOutsideWorkspacesWithoutReplacingItsNavigation(_ destination: WindowDestination) {
let window = WindowModel()
window.showAssistantArchive()
window.selectedDestination = destination
window.showsSidebar = false
window.showAssistant()
window.showAssistant()
#expect(window.selectedDestination == .assistant)
#expect(window.showsSidebar)
#expect(window.sidebarMode == .assistant)
#expect(window.isAssistantArchivePresented)
#expect(window.assistantLibrary.conversations.isEmpty)
}
@Test func showAssistantKeepsTheCurrentWorkspaceAndConversation() {
let window = WindowModel()
let conversation = window.assistantLibrary.createConversation()
let workspace = Workspace(pane: Pane(.empty))
window.open(workspace)
window.openAssistantInSidebar(conversation.id)
window.showsSidebar = false
window.sidebarMode = .navigation
window.showAssistant()
window.showAssistant()
#expect(window.selectedWorkspace === workspace)
#expect(window.showsSidebar && window.sidebarMode == .assistant)
#expect(window.assistantLibrary.selectedConversation === conversation)
}
@Test
func sshAndTelnetPanesUseDistinctProtocolSymbols() {
let host = Host(
name: "Console", host: "console.example.invalid",
telnetEnabled: true)
#expect(Pane.sshSession(host: host).symbol == MenuSymbol.ssh)
#expect(Pane.telnetSession(host: host).symbol == MenuSymbol.telnet)
#expect(MenuSymbol.ssh != MenuSymbol.telnet)
}
@Test
func assistantManualOrderMovesAcrossPinnedBoundaryAndStaysStableAfterActivity() throws {
let first = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 10))
let second = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 20))
let pinned = ConversationModel(updatedAt: Date(timeIntervalSinceReferenceDate: 30))
pinned.isPinned = true
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [first, second, pinned]
))
let updates = try #require(
library.orderUpdates(
moving: first.id, toPinned: true, before: pinned.id
))
library.applyOrder(updates)
#expect(library.pinnedConversations.map(\.id) == [first.id, pinned.id])
#expect(library.activeConversations.map(\.id) == [second.id])
first.updatedAt = Date(timeIntervalSinceReferenceDate: 100)
#expect(library.pinnedConversations.map(\.id) == [first.id, pinned.id])
}
@Test
func newWindowStartsOnWelcome() {
let window = WindowModel()
#expect(window.selectedDestination == .vault)
#expect(window.vaultDestination == .welcome)
}
@Test
func numericWorkspaceSelectionExcludesVaultAndChats() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace])
window.select(index: 0)
#expect(window.selectedDestination == .workspace(workspace.id))
window.selectVault()
window.select(index: 1)
#expect(window.selectedDestination == .vault)
window.selectVault()
window.closeSelected()
#expect(window.workspaces.map(\.id) == [workspace.id])
}
@Test
func closingTheLastWorkspaceKeepsTheGlobalSidebarMode() {
let workspace = Workspace.sshSession(
host: Host(name: "Server", host: "server.example")
)
let window = WindowModel(workspaces: [workspace])
window.sidebarMode = .history
window.close(workspace.id)
window.reconcileSidebarMode()
#expect(window.workspaces.isEmpty)
#expect(window.selectedDestination == .vault)
#expect(window.sidebarMode == .history)
}
@Test(arguments: [false, true])
func closingWorkspacesPromotesTheVisibleSidebarConversationFullScreen(closeAll: Bool) {
let workspace = Workspace.sshSession(
host: Host(name: "Server", host: "server.example")
)
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.sidebarMode = .assistant
window.openAssistantInSidebar(conversation.id)
if closeAll {
window.closeAll()
} else {
window.close(workspace.id)
}
#expect(window.workspaces.isEmpty)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation === conversation)
#expect(window.sidebarMode == .assistant)
}
@Test
func closingTheLastWorkspacePromotesTheVisibleSidebarArchiveFullScreen() {
let workspace = Workspace.sshSession(
host: Host(name: "Server", host: "server.example")
)
let window = WindowModel(workspaces: [workspace])
window.sidebarMode = .assistant
window.showAssistantArchiveInSidebar()
window.close(workspace.id)
#expect(window.workspaces.isEmpty)
#expect(window.selectedDestination == .assistant)
#expect(window.isAssistantArchivePresented)
#expect(window.assistantLibrary.selectedConversation == nil)
}
@Test
func assistantFeatureOwnsConversationLifecycleOutsideWorkspaces() {
let window = WindowModel()
let first = window.assistantLibrary.createConversation()
let second = window.assistantLibrary.createConversation()
window.selectAssistant(second.id)
#expect(window.selectedDestination == .assistant)
#expect(window.workspaces.isEmpty)
#expect(window.assistantLibrary.conversations.map(\.id) == [second.id, first.id])
window.assistantLibrary.delete(second.id)
#expect(window.assistantLibrary.selectedConversationID == first.id)
}
@Test
func assistantLibrarySeparatesPinnedActiveAndArchivedByRecentActivity() {
let old = ConversationModel(updatedAt: Date(timeIntervalSince1970: 1))
let recent = ConversationModel(updatedAt: Date(timeIntervalSince1970: 3))
let pinned = ConversationModel(updatedAt: Date(timeIntervalSince1970: 2))
pinned.isPinned = true
let archived = ConversationModel(updatedAt: Date(timeIntervalSince1970: 4))
archived.archivedAt = .now
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [old, recent, pinned, archived]
))
#expect(library.pinnedConversations.map(\.id) == [pinned.id])
#expect(library.activeConversations.map(\.id) == [recent.id, old.id])
#expect(library.archivedConversations.map(\.id) == [archived.id])
}
@Test
func archivingClearsPinAndUnarchivingReturnsTheConversationToChats() {
let conversation = ConversationModel()
conversation.isPinned = true
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.setAssistantArchived(conversation.id, archived: true)
#expect(conversation.isArchived)
#expect(!conversation.isPinned)
#expect(library.archivedConversations.map(\.id) == [conversation.id])
window.setAssistantArchived(conversation.id, archived: false)
#expect(!conversation.isArchived)
#expect(library.activeConversations.map(\.id) == [conversation.id])
}
@Test
func archivingTheOpenConversationReturnsMainAndSidebarSurfacesToNewChat() {
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.selectAssistant(conversation.id)
window.openAssistantInSidebar(conversation.id)
window.setAssistantArchived(conversation.id, archived: true)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation == nil)
#expect(window.sidebarAssistantConversation == nil)
#expect(!window.isAssistantArchivePresented)
}
@Test
func archiveIsASeparateWindowDestinationAndConversationNavigationLeavesIt() {
let conversation = ConversationModel()
conversation.archivedAt = .now
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.showAssistantArchive()
#expect(window.isAssistantArchivePresented)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation == nil)
window.selectAssistant(conversation.id)
#expect(!window.isAssistantArchivePresented)
#expect(window.assistantLibrary.selectedConversation === conversation)
window.showAssistantArchiveInSidebar()
#expect(window.isAssistantArchivePresented)
#expect(window.sidebarAssistantConversation == nil)
window.openAssistantInSidebar(conversation.id)
#expect(!window.isAssistantArchivePresented)
#expect(window.sidebarAssistantConversation === conversation)
}
@Test
func openingConversationFullScreenKeepsAssistantSidebarSelected() {
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.openAssistantFullScreen(conversation.id)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation === conversation)
#expect(window.sidebarMode == .assistant)
#expect(window.sidebarAssistantConversation === conversation)
}
@Test
func openingRecentHostKeepsTheGlobalHistoryMode() throws {
let host = Host(name: "Recent", host: "recent.example")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let window = WindowModel()
window.sidebarMode = .history
let workspace = try #require(app.open(host, as: .sshSession, in: window))
#expect(window.selectedDestination == .workspace(workspace.id))
#expect(window.sidebarMode == .history)
window.selectVault()
window.select(workspace.id)
#expect(window.sidebarMode == .history)
}
@Test
func forkTitlesUseTheLowestFreeWorkspaceStyleNumber() {
let source = ConversationModel()
source.customTitle = "Question"
let store = AssistantConversationStore(conversations: [source])
#expect(store.nextForkTitle(for: source) == "Question (1)")
let firstFork = ConversationModel()
firstFork.customTitle = "Question (1)"
store.insert(firstFork)
#expect(store.nextForkTitle(for: source) == "Question (2)")
#expect(store.nextForkTitle(for: firstFork) == "Question (2)")
store.remove(firstFork.id)
#expect(store.nextForkTitle(for: source) == "Question (1)")
}
@Test(arguments: [AgentRunState.idle, .running, .waitingForApproval], [false, true])
func failedMetadataSaveKeepsConversationStateAndReportsTheError(
state: AgentRunState, compacting: Bool
) async {
let repository = FailingConversationOperationRepository()
let hosts = HostLibraryModel()
let dependencies = AppDependencies(
hostRepository: InMemoryHostRepository(), outboxRunner: nil,
agentRunRepository: repository
)
let app = AppModel(hosts: hosts, dependencies: dependencies)
let window = WindowModel()
app.registerWindow(window)
let conversation = window.assistantLibrary.createConversation()
conversation.handle(.runState(state))
if compacting { conversation.beginCompaction() }
window.toggleAssistantPin(conversation.id)
for _ in 0..<100 {
if window.assistantOperationError != nil { break }
await Task.yield()
}
#expect(!conversation.isPinned)
#expect(window.assistantOperationError == "Storage unavailable")
#expect(conversation.runState == state)
#expect(conversation.isCompacting == compacting)
}
@Test
func failedCompactionSaveDoesNotChangeTheInMemoryContext() async throws {
let repository = FailingConversationOperationRepository()
let hosts = HostLibraryModel()
let app = AppModel(
hosts: hosts,
dependencies: AppDependencies(
hostRepository: InMemoryHostRepository(), outboxRunner: nil,
agentRunRepository: repository
)
)
let conversation = ConversationModel()
// Both turns are sized in tokens, because that is the unit the cut is
// chosen in. A last turn worth less than `keepRecentTokens` never
// satisfies the budget, so the search walks past it to the start of the
// transcript and `compactionSpan` returns nil with nothing to compact.
let keepRecentTokens = 2
conversation.appendUserTurn("old " + filler(tokens: 1_000))
conversation.appendUserTurn("recent " + filler(tokens: keepRecentTokens))
let span = try #require(
conversation.compactionSpan(keepRecentTokens: keepRecentTokens)
)
let record = try #require(
conversation.prepareCompaction(
summary: "summary", cut: span.cut
))
let transcript = conversation.transcript
let estimate = conversation.contextEstimate
await #expect(throws: (any Error).self) {
try await app.persistAndApplyCompaction(record, to: conversation, cut: span.cut)
}
#expect(conversation.transcript == transcript)
#expect(conversation.contextEstimate == estimate)
#expect(conversation.compactions.isEmpty)
}
/// Each window used to build its own `ConversationModel` for the same
/// conversation id, so a reply streaming in one window was invisible in the
/// other and both could start a run against the same stored history.
@Test
func twoWindowsShareOneInstanceOfEachConversationButNotTheSelection() {
let shared = AssistantConversationStore()
let first = WindowModel()
let second = WindowModel()
first.assistantLibrary.adopt(shared)
second.assistantLibrary.adopt(shared)
let conversation = first.assistantLibrary.createConversation()
let other = first.assistantLibrary.createConversation()
#expect(second.assistantLibrary.conversations.map(\.id) == [other.id, conversation.id])
#expect(second.assistantLibrary.conversation(id: conversation.id) === conversation)
second.selectAssistant(conversation.id)
#expect(second.assistantLibrary.selectedConversationID == conversation.id)
#expect(first.assistantLibrary.selectedConversationID == other.id)
// A delete in one window empties the other's stale selection too.
first.assistantLibrary.delete(conversation.id)
second.assistantLibrary.delete(conversation.id)
#expect(second.assistantLibrary.conversations.map(\.id) == [other.id])
#expect(second.assistantLibrary.selectedConversationID == other.id)
}
@Test
func aWindowThatAdoptsTheStoreLateKeepsWhatItAlreadyHad() {
let window = WindowModel()
let early = window.assistantLibrary.createConversation()
let shared = AssistantConversationStore()
window.assistantLibrary.adopt(shared)
#expect(shared.conversations.map(\.id) == [early.id])
#expect(window.assistantLibrary.selectedConversationID == early.id)
}
@Test
func selectingAssistantDoesNotCreateAConversation() {
let window = WindowModel()
window.activateSidebarMode(.assistant)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.conversations.isEmpty)
#expect(window.assistantLibrary.selectedConversation == nil)
}
@Test
func assistantAndNavigatorModeRailRoundTripsToThePreviousVaultRoute() {
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.selectAssistant(conversation.id)
window.openVaultWork(.hostEditor(nil), on: .hosts)
// The Assistant mode can already be selected after navigation elsewhere;
// pressing its selected button must still activate the Assistant route.
window.sidebarMode = .assistant
window.activateSidebarMode(.assistant)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation === conversation)
window.activateSidebarMode(.navigation)
#expect(window.selectedDestination == .vault)
#expect(window.vaultDestination == .hosts)
#expect(window.vaultWorkRoute == .hostEditor(nil))
}
@Test
func assistantModeRailStaysCompactInWorkspaceAndNavigatorReturnsThereFromFullScreen() {
let workspace = Workspace.sshSession(
host: Host(name: "Server", host: "server.example")
)
let window = WindowModel(workspaces: [workspace])
window.activateSidebarMode(.assistant)
#expect(window.sidebarMode == .assistant)
#expect(window.selectedDestination == .workspace(workspace.id))
let conversation = window.assistantLibrary.createConversation()
window.openAssistantFullScreen(conversation.id)
window.activateSidebarMode(.navigation)
#expect(window.sidebarMode == .navigation)
#expect(window.selectedDestination == .workspace(workspace.id))
#expect(window.sidebarAssistantConversation === conversation)
}
@Test
func navigatorFallsBackToVaultWhenTheAssistantReturnWorkspaceWasClosed() {
let workspace = Workspace.sshSession(
host: Host(name: "Server", host: "server.example")
)
let window = WindowModel(workspaces: [workspace])
window.openAssistantFullScreen()
window.close(workspace.id)
window.activateSidebarMode(.navigation)
#expect(window.workspaces.isEmpty)
#expect(window.selectedDestination == .vault)
}
@Test
func nonNavigationToolModesAndDirectModeAssignmentDoNotNavigate() {
let window = WindowModel()
window.openPage(.hosts)
window.sidebarMode = .assistant
#expect(window.selectedDestination == .vault)
window.activateSidebarMode(.snippets)
#expect(window.selectedDestination == .vault)
window.activateSidebarMode(.history)
#expect(window.selectedDestination == .vault)
}
@Test
func enteringAssistantKeepsTheVolatileConversationSelection() {
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.selectAssistant(conversation.id)
window.selectVault()
window.activateSidebarMode(.assistant)
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation === conversation)
let draft = window.openNewAssistantChat()
#expect(window.assistantLibrary.selectedConversation == nil)
#expect(draft === window.assistantLibrary.draftConversation)
window.selectVault()
window.activateSidebarMode(.assistant)
#expect(window.assistantLibrary.selectedConversation == nil)
#expect(draft === window.assistantLibrary.draftConversation)
}
@Test
func enteringAssistantKeepsTheVolatileArchiveDestination() {
let conversation = ConversationModel()
let library = AssistantLibraryModel(
store: AssistantConversationStore(
conversations: [conversation]
))
let window = WindowModel(assistantLibrary: library)
window.selectAssistant(conversation.id)
window.showAssistantArchive()
window.selectVault()
window.activateSidebarMode(.assistant)
#expect(window.isAssistantArchivePresented)
#expect(window.assistantLibrary.selectedConversation == nil)
}
@Test
func assistantStartsOnNewChatWhenNoVolatileSelectionExists() {
let previousID = UUID()
let library = AssistantLibraryModel()
library.restore([
StoredAgentConversation(
id: previousID,
messages: [AgentTranscriptMessage(role: .user, text: "Previous question")],
lastRunState: .completed
)
])
let window = WindowModel(assistantLibrary: library)
window.selectAssistant()
#expect(window.selectedDestination == .assistant)
#expect(window.assistantLibrary.selectedConversation == nil)
#expect(window.assistantLibrary.draftConversation.isEmpty)
#expect(window.assistantLibrary.conversations.map(\.id) == [previousID])
}
@Test
func newChatJoinsHistoryOnlyAfterItsFirstSend() async {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
let draft = window.openNewAssistantChat()
#expect(window.assistantLibrary.selectedConversation == nil)
#expect(window.assistantLibrary.conversations.isEmpty)
draft.composer.draft = "Hello from a new chat"
let started = app.sendNewAgentDraft(in: window.assistantLibrary)
#expect(started === draft)
#expect(window.assistantLibrary.selectedConversation === draft)
#expect(window.assistantLibrary.conversations.map(\.id) == [draft.id])
#expect(window.assistantLibrary.draftConversation !== draft)
await started?.stopAndWait()
}
@Test
func sidebarDraftCreatesConversationOnlyOnFirstValidSend() async {
let app = AppModel(hosts: HostLibraryModel())
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let window = WindowModel(workspaces: [workspace])
#expect(app.sendNewAgentConversation(window.sidebar.assistantDraft, in: window.assistantLibrary) == nil)
#expect(window.assistantLibrary.conversations.isEmpty)
window.sidebar.assistantDraft.composer.draft = " Explain this failure. "
let conversation = app.sendNewAgentConversation(
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
#expect(conversation?.messages.map(\.text) == ["Explain this failure."])
#expect(window.assistantLibrary.conversations.count == 1)
#expect(window.assistantLibrary.conversations.first?.id == conversation?.id)
#expect(window.sidebarAssistantConversation?.id == conversation?.id)
#expect(window.selectedDestination == .workspace(workspace.id))
#expect(window.sidebar.assistantDraft.isEmpty)
await conversation?.stopAndWait()
}
@Test
func sidebarConversationNavigationSharesSelectionAndDeletionReturnsHome() {
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let conversation = ConversationModel()
conversation.composer.draft = "Hello"
let library = AssistantLibraryModel()
conversation.sendDraft()
library.insertAndSelect(conversation)
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.openAssistantInSidebar(conversation.id)
#expect(window.sidebarAssistantConversation?.id == conversation.id)
#expect(window.assistantLibrary.selectedConversation === conversation)
#expect(window.selectedDestination == .workspace(workspace.id))
window.closeAssistantInSidebar()
#expect(window.sidebarAssistantConversation == nil)
#expect(window.assistantLibrary.selectedConversation == nil)
window.openAssistantInSidebar(conversation.id)
window.deleteAssistantConversation(conversation.id)
#expect(window.sidebarAssistantConversation == nil)
#expect(window.assistantLibrary.conversations.isEmpty)
}
@Test
func assistantSidebarShowsFocusedWorkspaceContext() {
let host = Host(name: "Files", host: "files.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app")
workspace.focusedPane.sftpChannel?.homeResolution = .unavailable
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
#expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host, .directory])
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv/app"])
// The id the host tools require, taken from the focused pane rather than
// left for the model to guess from the name.
#expect(window.sidebar.sessionContext.hostID == workspace.focusedPane.hostID)
#expect(window.sidebar.sessionContext.hostID == host.id)
#expect(window.sidebar.sessionContext.paneID == nil)
}
@Test
func terminalContextFollowsPaneFocusWithoutConfusingHostAndPaneIDs() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let first = workspace.focusedPane
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
let submitted = window.sidebar.sessionContext
#expect(submitted.paneID == first.id)
#expect(submitted.hostID == host.id)
workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: host))
#expect(window.sidebar.sessionContext.paneID == workspace.focusedPane.id)
#expect(window.sidebar.sessionContext.paneID != submitted.paneID)
#expect(window.sidebar.sessionContext.hostID == submitted.hostID)
#expect(submitted.modelContext?.contains("pane_id: \(first.id.uuidString)") == true)
workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host))
#expect(window.sidebar.sessionContext.paneID == nil)
workspace.focus(first.id)
#expect(window.sidebar.sessionContext.paneID == first.id)
window.selectAssistant()
window.refreshSessionContext()
#expect(window.sidebar.sessionContext == .none)
}
@Test
func focusedSFTPPathChangesUpdateSessionContext() {
let host = Host(name: "Files", host: "files.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv")
let sftpChannel = workspace.focusedPane.sftpChannel!
sftpChannel.homeResolution = .unavailable
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
sftpChannel.navigate(to: "/srv/releases")
#expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host, .directory])
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv/releases"])
sftpChannel.goBack()
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/srv"])
}
@Test
func backgroundSFTPPathDoesNotReplaceFocusedPaneContext() {
let host = Host(name: "Files", host: "files.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/focused")
let focusedPane = workspace.focusedPane
focusedPane.sftpChannel?.homeResolution = .unavailable
workspace.splitFocusedPane(
axis: .horizontal,
with: .sftpChannel(host: host, initialPath: "/background")
)
let backgroundSession = workspace.focusedPane.sftpChannel!
workspace.focus(focusedPane.id)
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
backgroundSession.navigate(to: "/background/changed")
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Files", "/focused"])
}
/// The reported leak: session context must belong to the sidebar, so a
/// conversation promoted out of it arrives in the Assistant destination carrying
/// nothing about a host it can no longer see.
@Test
func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() async {
let app = AppModel(hosts: HostLibraryModel())
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app")
workspace.focusedPane.sftpChannel?.homeResolution = .unavailable
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
window.sidebar.assistantDraft.composer.draft = "Why is this slow?"
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
let conversation = app.sendNewAgentConversation(
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
#expect(conversation?.composer.attachments.isEmpty == true)
#expect(conversation?.transcript.first?.contextSnapshot?.sessionContext?.contains("/srv/app") == true)
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
await conversation?.stopAndWait()
}
/// Leaving every Workspace used to freeze the last host and directory on
/// screen, because the refresh returned early instead of clearing.
@Test
func leavingEveryWorkspaceClearsSessionContext() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app")
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
#expect(!window.sidebar.sessionContext.isEmpty)
window.selectVault()
window.refreshSessionContext()
#expect(window.sidebar.sessionContext == .none)
}
/// The Ask Assistant path attaches the failure log, promotes the draft, and
/// the log goes with the message it belongs to rather than sitting in the
/// draft as text the user could half-delete.
@Test
func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() async {
let app = AppModel(hosts: HostLibraryModel())
let conversation = ConversationModel()
conversation.attach(
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
)
conversation.composer.draft = "Explain this"
#expect(app.sendNewAgentConversation(conversation, in: AssistantLibraryModel()) === conversation)
#expect(conversation.messages.first?.text == "Explain this")
#expect(conversation.transcript.first?.text.contains("ssh: refused") == true)
#expect(conversation.composer.attachments.isEmpty, "the attachment went with the turn")
#expect(conversation.transcript.first?.contextSnapshot != nil)
await conversation.stopAndWait()
}
@Test
func sshFailureAssistantDraftKeepsTheExistingPromptAndLastFourThousandLogCharacters() throws {
let conversation = ConversationModel()
let retainedLog = String(repeating: "x", count: 3_995) + "-tail"
let log = "discarded-prefix" + retainedLog
let message = "Connection refused"
SSHFailureAssistantDraft.populate(
conversation, message: message, log: log
)
let attachment = try #require(conversation.composer.attachments.first)
#expect(conversation.composer.attachments.count == 1)
#expect(attachment.kind == .output)
#expect(attachment.label == String(localized: "Connection Log"))
#expect(attachment.content == retainedLog)
#expect(attachment.content.count == 4_000)
#expect(
conversation.composer.draft
== String(localized: "SSH connection failed: \(message). What should I check?")
)
}
/// Opening a conversation in the sidebar used to stamp the focused pane's
/// host onto it, which is how that host followed the conversation into the
/// Assistant destination.
@Test
func openingASidebarConversationLeavesItUnmarkedByTheFocusedPane() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let conversation = ConversationModel()
conversation.composer.draft = "Explain this"
let library = AssistantLibraryModel()
conversation.sendDraft()
library.insertAndSelect(conversation)
conversation.attach(
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
)
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.hostStore = HostLibraryModel(hosts: [host])
window.openAssistantInSidebar(conversation.id)
#expect(conversation.composer.attachments.map(\.label) == ["Connection error"])
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server"])
}
@Test
func focusedSSHSystemInfoUpdatesSessionContextAndSFTPClearsIt() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let sshPane = workspace.focusedPane
let sshSession = sshPane.sshSession!
let window = WindowModel(workspaces: [workspace])
window.hostStore = HostLibraryModel(hosts: [host])
let info = RemoteSystemInfo(
distributionID: "ubuntu", distributionVersion: "24.04",
kernelName: "Linux", kernelRelease: "6.8.0", architecture: "x86_64"
)
sshSession.receiveShellIntegrationEvent(
.available(shell: "bash", systemInfo: info, homeDirectory: "/home/test")
)
#expect(window.sidebar.sessionContext.systemInfo == info)
// OS facts reach the model but are not a pill: they are disclosed by the
// Shell Integration setting, not by the composer.
#expect(window.sidebar.sessionContext.chips.map(\.kind) == [.host])
workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host))
#expect(window.sidebar.sessionContext.systemInfo == nil)
workspace.focus(sshPane.id)
#expect(window.sidebar.sessionContext.systemInfo == info)
sshSession.receiveShellIntegrationEvent(
.available(shell: "bash", systemInfo: info, homeDirectory: "/home/test")
)
sshSession.disconnect()
#expect(window.sidebar.sessionContext.systemInfo == nil)
}
@Test
func vaultRoutesNeverBecomeWorkspaceTabs() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace])
window.openPage(.hosts)
#expect(window.selectedDestination == .vault)
#expect(window.vaultDestination == .hosts)
#expect(window.workspaces.map(\.id) == [workspace.id])
window.select(workspace.id)
#expect(window.selectedDestination == .workspace(workspace.id))
}
@Test(arguments: [true, false])
func draggingAWorkspacePreservesSelectionAndTemporarilyDisplaysItsMergeTarget(dragSelected: Bool) {
let first = Workspace.sshSession(host: Host(name: "First", host: "first.example"))
let second = Workspace.sshSession(host: Host(name: "Second", host: "second.example"))
let window = WindowModel(workspaces: [first, second])
window.select(first.id)
window.dragging = .workspace(dragSelected ? first.id : second.id)
#expect(window.displayedWorkspace?.id == (dragSelected ? second.id : first.id))
#expect(window.selectedWorkspace?.id == first.id)
#expect(window.selectedWorkspaceID == first.id)
window.dragging = nil
#expect(window.displayedWorkspace?.id == first.id)
#expect(window.selectedWorkspaceID == first.id)
}
@Test
func draggingSelectedWorkspaceWithoutMergeNeighbourKeepsItsSelectionDisplayed() {
let workspace = Workspace.sshSession(host: Host(name: "Only", host: "only.example"))
let window = WindowModel(workspaces: [workspace])
window.select(workspace.id)
window.dragging = .workspace(workspace.id)
#expect(window.displayedWorkspace?.id == workspace.id)
#expect(window.selectedWorkspaceID == workspace.id)
}
@Test
func droppingNestedPaneOnNavigatorDetachesItIntoSelectedWorkspace() throws {
let workspace = Workspace.sshSession(
host: Host(name: "First", host: "first.example")
)
let firstPane = workspace.focusedPane
workspace.splitFocusedPane(
axis: .horizontal,
with: .sshSession(host: Host(name: "Second", host: "second.example"))
)
let detachedPane = workspace.focusedPane
workspace.splitFocusedPane(
axis: .vertical,
with: .sshSession(host: Host(name: "Third", host: "third.example"))
)
let thirdPane = workspace.focusedPane
let window = WindowModel(workspaces: [workspace])
let detachedWorkspace = try #require(window.detachPane(detachedPane.id, at: 0))
#expect(window.workspaces.map(\.id) == [detachedWorkspace.id, workspace.id])
#expect(detachedWorkspace.panes.map(\.id) == [detachedPane.id])
#expect(workspace.panes.map(\.id) == [firstPane.id, thirdPane.id])
#expect(window.selectedWorkspace === detachedWorkspace)
}
/// The inverse of a merge, in one move. Merging is a single gesture, so
/// undoing it must be too — and the last pane cannot be dragged out at all,
/// because a pane alone in its Workspace has nothing to detach from.
@Test
func separatingPanesUndoesAMergeKeepingLayoutOrder() throws {
let workspace = Workspace.sshSession(
host: Host(name: "First", host: "first.example")
)
let first = workspace.focusedPane
workspace.splitFocusedPane(
axis: .horizontal,
with: .sshSession(host: Host(name: "Second", host: "second.example"))
)
let second = workspace.focusedPane
workspace.splitFocusedPane(
axis: .vertical,
with: .sshSession(host: Host(name: "Third", host: "third.example"))
)
let third = workspace.focusedPane
let neighbour = Workspace.sshSession(
host: Host(name: "Other", host: "other.example")
)
let window = WindowModel(workspaces: [workspace, neighbour])
let laidOut = workspace.panes.map(\.id)
window.separatePanes(in: workspace.id)
// One Workspace per pane, in the order the panes were laid out, all
// before the Workspace that already followed them.
#expect(window.workspaces.count == 4)
#expect(window.workspaces.flatMap { $0.panes.map(\.id) } == laidOut + [neighbour.panes[0].id])
#expect(window.workspaces.allSatisfy { $0.panes.count == 1 })
// The row that was right-clicked is still there, and still selected.
#expect(window.workspaces.first?.id == workspace.id)
#expect(window.selectedWorkspaceID == workspace.id)
#expect(Set([first.id, second.id, third.id]) == Set(laidOut))
}
/// Nothing to take apart is not an error, and must not reorder anything.
@Test
func separatingPanesLeavesASingleePaneWorkspaceAlone() {
let workspace = Workspace.sshSession(host: Host(name: "Only", host: "only.example"))
let window = WindowModel(workspaces: [workspace])
window.separatePanes(in: workspace.id)
#expect(window.workspaces.map(\.id) == [workspace.id])
#expect(workspace.panes.count == 1)
}
@Test
func hostEditorIsVaultNavigationAndCloseWorkspaceCannotCloseHiddenWork() {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace])
window.openHostEditor(host: host)
#expect(window.selectedDestination == .vault)
#expect(window.vaultDestination == .hosts)
#expect(window.vaultWorkRoute == .hostEditor(host.id))
window.closeSelected()
#expect(window.workspaces.map(\.id) == [workspace.id])
}
@Test
func newHostEditorCarriesOnlyCreationGroupContext() {
let window = WindowModel()
let groupID = UUID()
window.openHostEditor(host: nil, groupID: groupID)
#expect(window.vaultWorkRoute == .hostEditor(nil, groupID: groupID))
let host = Host(name: "Server", host: "server.example", groupID: UUID())
window.openHostEditor(host: host, groupID: groupID)
#expect(window.vaultWorkRoute == .hostEditor(host.id, groupID: nil))
}
@Test(arguments: [false, true])
func tailscaleDetailsFollowEligibleHostsAndCloseForAnOrdinaryHost(_ isManaged: Bool) {
let first = Host(name: "First", host: "first.example")
var second = Host(name: "Second", host: "second.example")
if isManaged {
second.tailscale = TailscaleHostReference(groupID: UUID(), stableNodeID: "second")
}
let window = WindowModel()
window.openVaultWork(.tailscaleDetails(first.id), on: .hosts)
window.updateTailscaleDetails(for: second, hasPeer: !isManaged)
#expect(window.vaultWorkRoute == .tailscaleDetails(second.id))
window.updateTailscaleDetails(for: first, hasPeer: false)
#expect(window.vaultWorkRoute == nil)
#expect(window.vaultDestination == .hosts)
window.updateTailscaleDetails(for: second, hasPeer: true)
#expect(window.vaultWorkRoute == nil)
}
@Test
func clearingTailscaleSelectionClosesDetailsWithoutAffectingOtherEditors() {
let host = Host(name: "Server", host: "server.example")
let window = WindowModel()
window.openVaultWork(.tailscaleDetails(host.id), on: .hosts)
window.updateTailscaleDetails(for: nil, hasPeer: false)
#expect(window.vaultWorkRoute == nil)
window.openHostEditor(host: host)
window.updateTailscaleDetails(for: nil, hasPeer: false)
#expect(window.vaultWorkRoute == .hostEditor(host.id))
}
@Test
func deletingTheDisplayedTailscaleHostClosesDetails() {
let host = Host(name: "Server", host: "server.example")
let window = WindowModel()
window.openVaultWork(.tailscaleDetails(host.id), on: .hosts)
window.closeWorkspaces(for: UUID())
#expect(window.vaultWorkRoute == .tailscaleDetails(host.id))
window.closeWorkspaces(for: host.id)
#expect(window.vaultWorkRoute == nil)
}
@Test
func changingVaultPageClosesRightColumnWork() {
let window = WindowModel(vaultDestination: .hosts)
window.openVaultWork(
.snippetEditor(snippetID: nil, packageID: nil), on: .snippets
)
#expect(window.vaultDestination == .snippets)
#expect(window.vaultWorkRoute == .snippetEditor(snippetID: nil, packageID: nil))
window.openPage(.keychain)
#expect(window.vaultDestination == .keychain)
#expect(window.vaultWorkRoute == nil)
}
@Test
func windowsKeepPresentationAndVaultSelectionIsolated() {
let first = WindowModel(vaultDestination: .hosts)
let second = WindowModel(vaultDestination: .hosts)
let host = UUID()
first.hostChooser = .connect
first.selectVaultItem(.host(host), on: .hosts)
#expect(first.hostChooser == .connect)
#expect(second.hostChooser == nil)
#expect(second.vaultSelection(for: .hosts).isEmpty)
}
@Test
func windowPresentationRouteIsMutuallyExclusive() {
let state = WindowPresentationState()
state.hostChooser = .connect
#expect(state.hostChooser == .connect)
state.isQuickOpenPresented = true
#expect(state.hostChooser == nil)
#expect(state.isQuickOpenPresented)
state.hostChooser = .batchStartup
#expect(state.hostChooser == .batchStartup)
#expect(!state.isQuickOpenPresented)
state.hostChooser = nil
#expect(state.route == nil)
}
@Test
func featureRoutesReplaceRatherThanStack() {
let window = WindowModel(vaultDestination: .hosts)
let groupID = UUID()
window.openVaultWork(
.hostGroupEditor(groupID: nil, parentID: nil), on: .hosts
)
window.openVaultWork(
.hostGroupEditor(groupID: groupID, parentID: nil), on: .hosts
)
#expect(
window.vaultWorkRoute
== .hostGroupEditor(groupID: groupID, parentID: nil)
)
let snippets = SnippetsPageState()
let batch = SnippetBatch(snippets: [])
snippets.confirmation = .executeDestructive(batch)
snippets.confirmation = .deleteSnippets(batch)
guard case .deleteSnippets = snippets.confirmation else {
Issue.record("Expected one active snippet confirmation route")
return
}
}
@Test
func protectedVaultDeletionRequestsRemainPendingUntilConfirmed() {
let host = Host(name: "Protected", host: "protected.example.invalid")
let hosts = HostsPageState()
hosts.requestDeletion(of: [host])
guard case .deleteHosts(let pendingHosts) = hosts.confirmation else {
Issue.record("Expected a pending Host deletion confirmation")
return
}
#expect(pendingHosts.map(\.id) == [host.id])
let identity = Identity(name: "Deploy", username: "deploy")
let keychain = KeychainPageState()
keychain.requestDeletion(of: [.identity(identity)])
#expect(keychain.deleting == [.identity(identity)])
}
@Test
func sessionsMixSSHAndSFTPInWorkspaceAndPaneOrder() {
let firstHost = Host(name: "First", host: "first.example")
let secondHost = Host(name: "Second", host: "second.example")
let mixed = Workspace.sshSession(host: firstHost)
let sshPane = mixed.focusedPane
mixed.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: firstHost))
let sftpPane = mixed.focusedPane
let secondSSH = Workspace.sshSession(host: secondHost)
let window = WindowModel(workspaces: [mixed, secondSSH])
#expect(window.sessionPanes.map(\.pane.id) == [sshPane.id, sftpPane.id, secondSSH.focusedPane.id])
#expect(window.sshSessionPanes.map(\.pane.id) == [sshPane.id, secondSSH.focusedPane.id])
#expect(window.sftpChannelPanes.map(\.pane.id) == [sftpPane.id])
#expect(window.sessionPanes.map(\.pane.symbol) == ["apple.terminal", "folder", "apple.terminal"])
}
@Test
func sftpTitlesUseHostNameWithoutPrefixAndFallBackToSFTP() {
let host = Host(name: "Uploads", host: "uploads.example")
let store = HostLibraryModel(hosts: [host])
let pane = Pane.sftpChannel(host: host)
let missing = Pane(.sftpChannel(SFTPChannelModel(hostID: UUID(), initialPath: "/")))
#expect(pane.title(in: store) == "Uploads")
#expect(pane.sidebarTitle(in: store) == "Uploads")
#expect(missing.title(in: store) == "SFTP")
#expect(missing.sidebarTitle(in: store) == "SFTP")
}
@Test
func selectingAndClosingUnifiedSessionPanesKeepsWorkspaceStateConsistent() {
let host = Host(name: "Host", host: "host.example")
let workspace = Workspace.sshSession(host: host)
let sshPane = workspace.focusedPane
workspace.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host))
let sftpPane = workspace.focusedPane
let window = WindowModel(workspaces: [workspace])
workspace.focus(sshPane.id)
window.select(workspace.id)
workspace.focus(sftpPane.id)
window.closePane(sftpPane.id)
#expect(window.sessionPanes.map(\.pane.id) == [sshPane.id])
#expect(workspace.focusedPaneID == sshPane.id)
window.closePane(sshPane.id)
#expect(window.sessionPanes.isEmpty)
#expect(window.workspaces.isEmpty)
}
@Test
func appRoutesHostsToSSHAndSFTPChannelsForWorkspaces() throws {
let first = Host(name: "First", host: "first.example")
let second = Host(name: "Second", host: "second.example")
let store = HostLibraryModel(hosts: [first, second])
let app = AppModel(hosts: store)
let window = WindowModel()
let sshTab = try #require(app.open(first, as: .sshSession, in: window))
let sftpTab = try #require(app.open(second, as: .sftpChannel, in: window))
#expect(sshTab.focusedPane.kind == .sshSession)
#expect(sftpTab.focusedPane.kind == .sftpChannel)
app.open([first, second], as: .sshSession, layout: .separateWorkspaces, in: window)
#expect(window.selectedWorkspaceID == window.workspaces[2].id)
#expect(window.workspaces.suffix(2).map(\.focusedPane.kind) == [.sshSession, .sshSession])
app.open([first, second], as: .sftpChannel, layout: .oneWorkspace, in: window)
#expect(window.selectedWorkspace?.panes.map(\.kind) == [.sftpChannel, .sftpChannel])
}
@Test
func reopeningAClosedTerminalCreatesAFreshConnectingSession() throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let pane = workspace.focusedPane
pane.customTitle = "Logs"
let closedSession = try #require(pane.sshSession)
let window = WindowModel(workspaces: [workspace])
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
app.registerWindow(window)
window.close(workspace.id)
#expect(closedSession.status == .disconnected)
#expect(!app.closedWorkspaceRecords.isEmpty)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
let reopenedPane = try #require(window.selectedWorkspace?.focusedPane)
let reopenedSession = try #require(reopenedPane.sshSession)
#expect(reopenedSession !== closedSession)
#expect(reopenedSession.hostID == host.id)
#expect(reopenedSession.status == .connecting)
#expect(reopenedPane.customTitle == "Logs")
#expect(window.selectedWorkspace === workspace)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test(arguments: [false, true])
func reopeningAClosedSplitPaneKeepsTheSurvivingSessionRunning(closeSelected: Bool) throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let survivor = workspace.focusedPane
let survivorSession = try #require(survivor.sshSession)
survivorSession.status = .connected
let closedPane = Pane.sshSession(host: host)
closedPane.customTitle = "Logs"
let closedSession = try #require(closedPane.sshSession)
closedSession.status = .connected
workspace.splitFocusedPane(axis: .vertical, with: closedPane)
workspace.broadcastsInput = true
let window = WindowModel(workspaces: [workspace])
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
app.registerWindow(window)
if closeSelected {
window.closeSelected()
} else {
window.closePane(closedPane.id)
}
#expect(!app.closedWorkspaceRecords.isEmpty)
#expect(closedSession.status == .disconnected)
#expect(workspace.panes.map(\.id) == [survivor.id])
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
let reopened = try #require(window.selectedWorkspace)
let reopenedSession = try #require(reopened.focusedPane.sshSession)
#expect(window.workspaces.count == 2)
#expect(reopened !== workspace)
#expect(reopened.panes.map(\.id) == [closedPane.id])
#expect(reopened.focusedPane.customTitle == "Logs")
#expect(reopenedSession !== closedSession)
#expect(reopenedSession.hostID == host.id)
#expect(reopenedSession.status == .connecting)
#expect(reopenedSession.inputRouter === reopened)
#expect(survivor.sshSession === survivorSession)
#expect(survivorSession.status == .connected)
#expect(survivorSession.inputRouter === workspace)
#expect(!workspace.broadcastsInput && !reopened.broadcastsInput)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test
func closedPanesAndWorkspacesReopenInReverseCloseOrder() throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let first = workspace.focusedPane
let second = Pane.sftpChannel(host: host, initialPath: "/var/log")
workspace.splitFocusedPane(axis: .horizontal, with: second)
let other = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [workspace, other])
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
app.registerWindow(window)
window.closePane(second.id)
window.close(other.id)
window.closePane(first.id)
#expect(window.workspaces.isEmpty)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.panes.map(\.id) == [first.id])
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
#expect(window.selectedWorkspace === other)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
let reopened = try #require(window.selectedWorkspace)
#expect(reopened.panes.map(\.id) == [second.id])
#expect(reopened.focusedPane.sftpChannel?.path == "/var/log")
#expect(window.workspaces.count == 3)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test
func individuallyClosedPanesShareTheFifteenItemReopenLimit() throws {
let survivor = Pane(.empty)
let workspace = Workspace(pane: survivor)
let window = WindowModel(workspaces: [workspace])
let app = AppModel(hosts: HostLibraryModel())
app.registerWindow(window)
let closedPanes = (0..<16).map { _ in Pane(.empty) }
for pane in closedPanes {
workspace.splitFocusedPane(axis: .horizontal, with: pane)
window.closePane(pane.id)
}
var reopenedIDs: [Pane.ID] = []
while !app.closedWorkspaceRecords.isEmpty {
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
if let pane = window.selectedWorkspace?.focusedPane { reopenedIDs.append(pane.id) }
}
#expect(reopenedIDs == closedPanes.suffix(15).reversed().map(\.id))
#expect(workspace.panes.map(\.id) == [survivor.id])
window.closePane(reopenedIDs[0])
#expect(!app.closedWorkspaceRecords.isEmpty)
window.closeForWindow()
#expect(app.closedWorkspaceRecords.first?.isWindow == true)
}
@Test
func terminalPathOpensSFTPInANewWorkspaceOrTheCurrentWorkspace() {
var host = Host(name: "Files", host: "files.example")
host.sftpChannelInitialPath = "/configured"
let sshWorkspace = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [sshWorkspace])
let newTab = window.openSFTPChannel(
host: host, initialPath: "/var/log/", placement: .newWorkspace
)
#expect(window.workspaces.count == 2)
#expect(window.selectedWorkspace === newTab)
#expect(newTab.focusedPane.sftpChannel?.path == "/var/log")
#expect(newTab.focusedPane.sftpChannel?.initialPath == "/var/log")
#expect(host.sftpChannelInitialPath == "/configured")
window.select(sshWorkspace.id)
let result = window.openSFTPChannel(
host: host, initialPath: "/srv/app", placement: .currentWorkspace
)
#expect(result === sshWorkspace)
#expect(window.workspaces.count == 2)
#expect(sshWorkspace.panes.map(\.kind) == [.sshSession, .sftpChannel])
#expect(sshWorkspace.focusedPane.sftpChannel?.path == "/srv/app")
}
@Test
func sftpPathOpensSSHInANewWorkspaceOrTheCurrentWorkspace() throws {
let host = Host(name: "Shell", host: "shell.example")
let sftpWorkspace = Workspace.sftpChannel(host: host, initialPath: "/configured")
let window = WindowModel(workspaces: [sftpWorkspace])
let newTab = window.openSSHSession(
host: host, initialPath: "/var/log", placement: .newWorkspace
)
#expect(window.workspaces.count == 2)
#expect(window.selectedWorkspace === newTab)
#expect(newTab.focusedPane.sshSession?.hostID == host.id)
window.select(sftpWorkspace.id)
let result = window.openSSHSession(
host: host, initialPath: "/srv/app", placement: .currentWorkspace
)
#expect(result === sftpWorkspace)
#expect(window.workspaces.count == 2)
#expect(sftpWorkspace.panes.map(\.kind) == [.sftpChannel, .sshSession])
#expect(sftpWorkspace.focusedPane.sshSession?.hostID == host.id)
}
@Test
func sftpCompanionUsesTheHostDefaultWithoutChangingExplicitSSH() throws {
for transport in [HostTerminalProtocol.ssh, .mosh, .eternalTerminal] {
var host = Host(name: "Shell", host: "shell.example")
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
host.terminalTransports.defaultTransport = transport
let channel = SFTPChannelModel.demo(hostID: host.id, path: "/srv/app")
let workspace = Workspace(pane: Pane(.sftpChannel(channel)))
let window = WindowModel(workspaces: [workspace])
let library = HostLibraryModel(hosts: [host])
window.hostStore = library
#expect(window.canOpenCompanionSession(from: channel.id))
let companion = try #require(
window.openCompanionSession(from: channel.id, placement: .newWorkspace))
#expect(companion.focusedPane.sshSession?.transport == transport)
#expect(window.openSSHSession(host: host).focusedPane.sshSession?.transport == .ssh)
window.select(workspace.id)
let split = try #require(
window.openCompanionSession(from: channel.id, placement: .currentWorkspace))
#expect(split === workspace)
#expect(split.focusedPane.sshSession?.transport == transport)
host.sshEnabled = false
host.telnetEnabled = true
#expect(library.saveHost(host))
#expect(!window.canOpenCompanionSession(from: channel.id))
#expect(window.openCompanionSession(from: channel.id, placement: .newWorkspace) == nil)
}
}
@Test
func sftpWithoutAnOverrideKeepsTheHostsConfiguredInitialPath() {
var host = Host(name: "Files", host: "files.example")
host.sftpChannelInitialPath = "/configured"
let window = WindowModel()
let workspace = window.openSFTPChannel(host: host)
#expect(workspace.focusedPane.sftpChannel?.path == "/configured")
#expect(workspace.focusedPane.sftpChannel?.initialPath == "/configured")
}
@Test
func duplicateDestinationsPreservePaneAndWorkspaceScope() {
let host = Host(name: "Host", host: "host.example")
let original = Workspace.sshSession(host: host)
let window = WindowModel(workspaces: [original])
window.duplicateSelected(axis: .horizontal)
#expect(window.workspaces.count == 1)
#expect(original.panes.count == 2)
let focusedPane = original.focusedPane
window.duplicatePaneInNewWorkspace()
#expect(window.workspaces.count == 2)
#expect(window.selectedWorkspace?.panes.count == 1)
#expect(window.selectedWorkspace?.focusedPane.kind == focusedPane.kind)
window.select(original.id)
window.duplicateSelectedInNewWorkspace()
#expect(window.workspaces.count == 3)
#expect(window.selectedWorkspace?.panes.map(\.kind) == original.panes.map(\.kind))
}
@Test
func normalRemoteExitClosesOnlyItsSplitPane() throws {
let firstHost = Host(name: "First", host: "first.example")
let secondHost = Host(name: "Second", host: "second.example")
let workspace = Workspace.sshSession(host: firstHost)
let firstPane = workspace.focusedPane
let firstSSHSession = firstPane.sshSession!
workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: secondHost))
let secondPane = workspace.focusedPane
workspace.focus(firstPane.id)
workspace.broadcastsInput = true
let window = WindowModel(workspaces: [workspace])
let app = AppModel(hosts: HostLibraryModel(hosts: [firstHost, secondHost]))
app.registerWindow(window)
firstSSHSession.handleRemoteExit(.success(0))
#expect(window.workspaces.count == 1)
#expect(workspace.panes.map(\.id) == [secondPane.id])
#expect(workspace.layout.pane(firstPane.id) == nil)
#expect(workspace.focusedPaneID == secondPane.id)
#expect(!workspace.broadcastsInput)
#expect(firstSSHSession.inputRouter == nil)
#expect(!app.closedWorkspaceRecords.isEmpty)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window)
#expect(window.selectedWorkspace?.focusedPane.id == firstPane.id)
#expect(window.selectedWorkspace?.focusedPane.sshSession !== firstSSHSession)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test
func resumableRemoteExitClosesOnlyItsSplitAndDoesNotTreatRecoveryAsExit() throws {
for transport in [HostTerminalProtocol.mosh, .eternalTerminal] {
let host = Host(name: "Server", host: "server.example")
let pane = Pane.sshSession(host: host, transport: transport)
let session = try #require(pane.sshSession)
let workspace = Workspace(pane: pane)
workspace.splitFocusedPane(axis: .horizontal, with: .sshSession(host: host))
let neighbor = workspace.focusedPane
let window = WindowModel(workspaces: [workspace])
session.receiveTransportState(.recovering)
session.receiveTransportState(.failed)
#expect(workspace.panes.count == 2)
session.receiveTransportState(.ended)
#expect(window.workspaces.count == 1)
#expect(workspace.panes.map(\.id) == [neighbor.id])
session.receiveTransportState(.connected)
session.receiveTransportState(.ended)
#expect(session.status == .disconnected)
#expect(workspace.panes.map(\.id) == [neighbor.id])
}
}
@Test
func normalRemoteExitClosesLastPaneAndSelectsNeighboringWorkspace() {
let host = Host(name: "Host", host: "host.example")
let sshSessionTab = Workspace.sshSession(host: host)
let sshSession = sshSessionTab.focusedPane.sshSession!
let neighbor = Workspace.sftpChannel(host: host)
let window = WindowModel(workspaces: [sshSessionTab, neighbor])
window.select(sshSessionTab.id)
sshSession.handleRemoteExit(.success(0))
#expect(window.workspaces.map(\.id) == [neighbor.id])
#expect(window.selectedWorkspaceID == neighbor.id)
}
@Test
func transportFailureKeepsPaneOpenForReconnect() {
let host = Host(name: "Host", host: "host.example")
let workspace = Workspace.sshSession(host: host)
let sshSession = workspace.focusedPane.sshSession!
let window = WindowModel(workspaces: [workspace])
sshSession.handleRemoteExit(.failure(.disconnected))
#expect(window.workspaces.map(\.id) == [workspace.id])
#expect(workspace.focusedPane.sshSession === sshSession)
guard case .failed = sshSession.status else {
Issue.record("Expected transport failure to remain visible")
return
}
}
@Test
func workspaceNamesUseTopLevelTitlesAcrossProtocolsAndHostIDs() {
let first = Host(name: "Server", host: "first.example")
let second = Host(name: "Server", host: "second.example", telnetEnabled: true)
let store = HostLibraryModel(hosts: [first, second])
let window = WindowModel()
window.hostStore = store
let ssh = window.openSSHSession(host: first)
let sftp = window.openSFTPChannel(host: second)
let telnet = window.openTelnetSession(host: second)
#expect([ssh, sftp, telnet].map { $0.title(in: store) } == ["Server", "Server (1)", "Server (2)"])
window.rename(sftp.id, to: "Logs")
let next = window.openSSHSession(host: first)
#expect(next.title(in: store) == "Server (3)")
window.close(ssh.id)
let reusedBase = window.openSFTPChannel(host: first)
#expect(reusedBase.title(in: store) == "Server")
}
@Test
func combinedPaneNamesAndOtherWindowsDoNotOccupyWorkspaceNames() {
let host = Host(name: "Server 2", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let combined = Workspace.sshSession(host: host)
combined.splitFocusedPane(axis: .horizontal, with: .sftpChannel(host: host))
let window = WindowModel(workspaces: [combined])
window.hostStore = store
let first = window.openSSHSession(host: host)
let second = window.openSFTPChannel(host: host)
#expect(first.title(in: store) == "Server 2")
#expect(second.title(in: store) == "Server 2 (1)")
let otherWindow = WindowModel()
otherWindow.hostStore = store
#expect(otherWindow.openSSHSession(host: host).title(in: store) == "Server 2")
}
@Test
func hostNamesThatEndInNumbersDoNotConflictWithParenthesizedSuffixes() {
let server = Host(name: "Server", host: "server.example")
let serverOne = Host(name: "Server 1", host: "server-one.example")
let store = HostLibraryModel(hosts: [server, serverOne])
let window = WindowModel()
window.hostStore = store
#expect(window.openSSHSession(host: server).title(in: store) == "Server")
#expect(window.openSSHSession(host: serverOne).title(in: store) == "Server 1")
#expect(window.openSSHSession(host: server).title(in: store) == "Server (1)")
#expect(window.openSSHSession(host: serverOne).title(in: store) == "Server 1 (1)")
}
@Test
func disablingAutomaticWorkspaceNamesLeavesExistingNamesAndStopsAddingNumbers() {
let host = Host(name: "Server", host: "server.example")
let store = HostLibraryModel(hosts: [host])
let window = WindowModel()
window.hostStore = store
let first = window.openSSHSession(host: host)
let second = window.openSSHSession(host: host)
window.workspacePreferences = {
WorkspacePreferences(automaticallyRenamesWorkspaces: false)
}
let third = window.openSFTPChannel(host: host)
#expect([first, second, third].map { $0.title(in: store) } == ["Server", "Server (1)", "Server"])
}
}
private actor FailingConversationOperationRepository: AgentRunPersisting {
private struct StorageFailure: LocalizedError {
var errorDescription: String? { "Storage unavailable" }
}
func save(_ snapshot: AgentRunSnapshot) {}
func record(_ event: AgentEvent, runID: UUID) {}
func persistCompaction(
_ record: AgentCompactionRecord, conversationID: UUID
) throws {
throw StorageFailure()
}
func loadConversations() -> [StoredAgentConversation] { [] }
func deleteConversation(_ id: UUID) {}
func setTitle(_ title: String, conversationID: UUID) {}
func setConversationMetadata(
_ metadata: StoredAgentConversationMetadata, conversationID: UUID
) throws {
throw StorageFailure()
}
func reorderConversations(_ updates: [StoredAgentConversationOrder]) throws {
throw StorageFailure()
}
func truncateConversation(
_ id: UUID, fromUserMessageID messageID: UUID,
replacingWith message: AgentTranscriptMessage
) throws {
throw StorageFailure()
}
func createConversation(_ conversation: StoredAgentConversation) throws {
throw StorageFailure()
}
}