1721 lines
71 KiB
Swift
1721 lines
71 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
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()
|
|
}
|
|
}
|