diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b9dea0e..72692df8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,7 +32,7 @@ framework and concrete-client restrictions that directory layout alone cannot. - Repository snapshots are application-wide and shared by every window. - A window owns one top-level shell, quick-open, and context-panel state. - The shell always starts with two pinned routes: `Vault`, then `Assistant`. Dynamic - tabs after them are SSH/SFTP Workspaces only. + dynamic destinations after them are SSH/SFTP Workspaces only. - Vault owns its navigation column, including quick-connect Recents, and routes for Hosts, Keychain, Snippets, Welcome, and Host Editor. These routes never enter the Workspace deck. @@ -56,6 +56,16 @@ store API: `HostLibraryModel`, `CredentialLibraryModel`, `SnippetLibraryModel`, vault snapshot still expose separate intent surfaces, so a feature cannot gain unrelated write operations merely because its records are saved together. +The shared Vault transaction is isolated behind `VaultStore`. Feature facades +keep their backing aggregate private, and Terminal/SFTP sessions depend on the +narrow `RemoteSessionHostStore` port rather than the complete Vault mutation +surface. + +Window navigation has one Navigator plus three tool sidebars: Assistant, +Snippets, and History. Dynamic SSH/SFTP containers are consistently named +Workspaces; “tab” is reserved for keyboard or native UI tab semantics. Vault +selection belongs to `VaultPageStates`, not to sidebar state. + ## Agent runtime and security `AgentRuntime` is an actor holding a run's state — transcript snapshot, tool diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index f221369d..84ed1efa 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -621,35 +621,30 @@ final class AppModel { window.commandHistoryStore = commandHistory window.sftpHistoryStore = sftpHistory window.assistantLibrary.adopt(assistantConversations) - window.assistantConversationDeletionHandler = { [weak self] id in - self?.deleteAgentConversation(id) - } - window.assistantPinHandler = { [weak self, weak window] id in - self?.togglePin(id, reportingTo: window) - } - window.assistantMoveHandler = { [weak self, weak window] id, pinned, nextID in - self?.moveConversation( - id, toPinned: pinned, before: nextID, reportingTo: window - ) - } - window.assistantRenameHandler = { [weak self, weak window] id, title in - self?.renameConversation(id, to: title, reportingTo: window) - } - window.assistantArchiveHandler = { [weak self, weak window] id, archived in - self?.setArchived(id, archived: archived, reportingTo: window) - } - window.assistantForkHandler = { [weak self, weak window] id, messageID, inSidebar in - self?.forkConversation( - id, at: messageID, selectingIn: window, inSidebar: inSidebar - ) - } - window.assistantEditHandler = { - [weak self, weak window] id, messageID, text, context, includes in - self?.editConversation( - id, messageID: messageID, text: text, context: context, - includesTerminalSuggestion: includes, reportingTo: window - ) - } + window.assistantCommands = AssistantConversationCommands( + delete: { [weak self] id in self?.deleteAgentConversation(id) }, + togglePin: { [weak self, weak window] id in + self?.togglePin(id, reportingTo: window) + }, + move: { [weak self, weak window] id, pinned, nextID in + self?.moveConversation(id, toPinned: pinned, before: nextID, reportingTo: window) + }, + rename: { [weak self, weak window] id, title in + self?.renameConversation(id, to: title, reportingTo: window) + }, + archive: { [weak self, weak window] id, archived in + self?.setArchived(id, archived: archived, reportingTo: window) + }, + fork: { [weak self, weak window] id, messageID, inSidebar in + self?.forkConversation(id, at: messageID, selectingIn: window, inSidebar: inSidebar) + }, + edit: { [weak self, weak window] id, messageID, text, context, includes in + self?.editConversation( + id, messageID: messageID, text: text, context: context, + includesTerminalSuggestion: includes, reportingTo: window + ) + } + ) Task { [agentRunRepository, weak window, weak self] in guard let restored = try? await agentRunRepository.loadConversations() else { return } let visible = restored.filter { diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index ecc696ee..bfa4a7e3 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -25,7 +25,7 @@ struct RootView: View { ZStack { SidebarMaterialBackground() .ignoresSafeArea() - SidebarView(detailCatalog: .standard(store: app.hostLibrary)) + WindowSidebarView() } .navigationSplitViewColumnWidth( min: Metrics.Sidebar.min, diff --git a/Termind/Application/Agent/AgentConnections.swift b/Termind/Application/Agent/AgentConnections.swift index 2c8910ee..cb454cd1 100644 --- a/Termind/Application/Agent/AgentConnections.swift +++ b/Termind/Application/Agent/AgentConnections.swift @@ -440,7 +440,7 @@ final class AgentHostResolver: Sendable { guard let host = store.host(id) else { // A pane-backed target has no Vault record to describe, and its // configuration is rebuilt from the live pane on every connect. Tie - // the fingerprint to that pane still existing, so a closed tab + // the fingerprint to that pane still existing, so a closed workspace // invalidates the Agent's connection to it. guard temporaryTargets().contains(where: { $0.id == id }) else { throw AgentToolError.invalidHost diff --git a/Termind/Application/Connections/RemoteSessionConnectionController.swift b/Termind/Application/Connections/RemoteSessionConnectionController.swift new file mode 100644 index 00000000..53b2efe8 --- /dev/null +++ b/Termind/Application/Connections/RemoteSessionConnectionController.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Shared connection-state composition for SSH-backed interactive sessions. +/// Transport-specific models own their I/O, while this object owns the common +/// connection journey and retry lifecycle shown by both Terminal and SFTP. +@Observable +final class RemoteSessionConnectionController { + let journey = SSHConnectionJourney() + let reconnect = SessionReconnectController() + + var reconnectState: ReconnectState? { reconnect.state } + + func retryNow() { reconnect.retryNow() } + func stopReconnecting() { reconnect.stop() } + + func disconnect() { + reconnect.stop() + journey.hide() + } +} diff --git a/Termind/Application/Connections/RemoteSessionHostStore.swift b/Termind/Application/Connections/RemoteSessionHostStore.swift new file mode 100644 index 00000000..f4d23c22 --- /dev/null +++ b/Termind/Application/Connections/RemoteSessionHostStore.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The host, trust, and credential capabilities required to establish a live +/// SSH-backed session. Session features depend on this port instead of gaining +/// the complete Vault mutation surface from `HostLibraryModel`. +@MainActor +protocol RemoteSessionHostStore: AnyObject, Sendable { + func host(_ id: Host.ID) -> Host? + func identity(_ id: Identity.ID) -> Identity? + func identity(for host: Host) -> Identity? + func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? + func knownHosts(for host: Host) -> [KnownHostEntry] + func route(for host: Host) -> [Host] + func expectedHostKeys( + for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> Set + func sshConnectionConfiguration( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> SSHConnectionConfiguration + func sshJumpConfigurations( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> [SSHRouteHopConfiguration] + func transportProxy(for host: Host) throws -> SSHTransportProxy? + func sshCredentials(for identity: Identity) -> SSHCredentialResolution + func passwordCredential(for identity: Identity) throws -> String? + func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? + func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) + func setSSHSessionStatus(_ status: ConnectionStatus, sshSessionID: UUID, hostID: Host.ID) + func trust(_ entry: KnownHostEntry) + func markKnownHostMismatched(hostname: String, port: Int) + @discardableResult func upsert(_ identity: Identity) -> Bool + func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws + func upsert(_ host: Host) +} + +extension HostLibraryModel: RemoteSessionHostStore {} diff --git a/Termind/Application/FeatureModels/AssistantConversationCommands.swift b/Termind/Application/FeatureModels/AssistantConversationCommands.swift new file mode 100644 index 00000000..fcec4770 --- /dev/null +++ b/Termind/Application/FeatureModels/AssistantConversationCommands.swift @@ -0,0 +1,33 @@ +import Foundation + +/// One injected command surface for durable Assistant conversation mutations. +/// Windows keep navigation state; AppModel owns persistence and cross-window +/// effects behind this cohesive service instead of installing seven callbacks. +@MainActor +final class AssistantConversationCommands { + let delete: (ConversationModel.ID) -> Void + let togglePin: (ConversationModel.ID) -> Void + let move: (ConversationModel.ID, Bool, ConversationModel.ID?) -> Void + let rename: (ConversationModel.ID, String) -> Void + let archive: (ConversationModel.ID, Bool) -> Void + let fork: (ConversationModel.ID, UUID, Bool) -> Void + let edit: (ConversationModel.ID, UUID, String, SessionContext, Bool) -> Void + + init( + delete: @escaping (ConversationModel.ID) -> Void, + togglePin: @escaping (ConversationModel.ID) -> Void, + move: @escaping (ConversationModel.ID, Bool, ConversationModel.ID?) -> Void, + rename: @escaping (ConversationModel.ID, String) -> Void, + archive: @escaping (ConversationModel.ID, Bool) -> Void, + fork: @escaping (ConversationModel.ID, UUID, Bool) -> Void, + edit: @escaping (ConversationModel.ID, UUID, String, SessionContext, Bool) -> Void + ) { + self.delete = delete + self.togglePin = togglePin + self.move = move + self.rename = rename + self.archive = archive + self.fork = fork + self.edit = edit + } +} diff --git a/Termind/Application/FeatureModels/AssistantLibraryModel.swift b/Termind/Application/FeatureModels/AssistantLibraryModel.swift index 49524dd1..feea5ea2 100644 --- a/Termind/Application/FeatureModels/AssistantLibraryModel.swift +++ b/Termind/Application/FeatureModels/AssistantLibraryModel.swift @@ -24,7 +24,7 @@ final class AssistantConversationStore { conversations.insert(conversation, at: 0) } - /// Forks use the same lowest-free numbering as duplicated terminal tabs: + /// Forks use the same lowest-free numbering as duplicated terminal workspaces: /// `Title (1)`, `Title (2)`, and a vacated number can be reused. Forking a /// fork continues the original sequence instead of producing `(1) (1)`. func nextForkTitle(for source: ConversationModel) -> String { @@ -140,7 +140,7 @@ final class AssistantConversationStore { final class AssistantLibraryModel { private var store: AssistantConversationStore var selectedConversationID: ConversationModel.ID? - /// Window-local scratch space for the Assistant tab's New Chat surface. + /// Window-local scratch space for the Assistant destination's New Chat surface. /// It is promoted into the shared history only after its first real send, /// so merely entering Assistant never creates an empty list row. private(set) var draftConversation = ConversationModel() diff --git a/Termind/Application/FeatureModels/CredentialLibraryModel.swift b/Termind/Application/FeatureModels/CredentialLibraryModel.swift index b94d2f03..9dded652 100644 --- a/Termind/Application/FeatureModels/CredentialLibraryModel.swift +++ b/Termind/Application/FeatureModels/CredentialLibraryModel.swift @@ -7,7 +7,7 @@ import Foundation /// persistence snapshot. @Observable final class CredentialLibraryModel { - let library: HostLibraryModel + private let library: HostLibraryModel init(library: HostLibraryModel) { self.library = library diff --git a/Termind/Application/FeatureModels/ForwardingLibraryModel.swift b/Termind/Application/FeatureModels/ForwardingLibraryModel.swift index b533d4be..07b168a0 100644 --- a/Termind/Application/FeatureModels/ForwardingLibraryModel.swift +++ b/Termind/Application/FeatureModels/ForwardingLibraryModel.swift @@ -3,7 +3,7 @@ import Foundation /// Read and write access needed by the port-forwarding feature. @Observable final class ForwardingLibraryModel { - let library: HostLibraryModel + private let library: HostLibraryModel init(library: HostLibraryModel) { self.library = library diff --git a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift index d8a5825c..eef13fc6 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Demo data extension HostLibraryModel { - /// Seed data for P0, so the sidebar and tab bar can be exercised before any + /// Seed data for P0, so the sidebar and workspace list can be exercised before any /// persistence or networking exists. static func demo() -> HostLibraryModel { let production = HostGroup(name: "Production", sortIndex: 0) @@ -201,7 +201,7 @@ extension HostLibraryModel { knownHosts: knownHosts ) - // A spread of connection states so the sidebar, tab bar, and status bar + // A spread of connection states so the sidebar, workspace list, and status bar // can be checked against every dot color without a server. store.setStatus(.connected, for: web01.id) store.setStatus(.connecting, for: db.id) diff --git a/Termind/Application/FeatureModels/HostLibraryModel.swift b/Termind/Application/FeatureModels/HostLibraryModel.swift index 932fbc6d..ddc59de9 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel.swift @@ -19,14 +19,20 @@ final class HostLibraryModel { /// The latest local persistence failure. Secret text is never included. private(set) var storageError: String? - private var persistence: (any VaultPersisting)? + private let vault: VaultStore private let keychain: any SecretStoring /// Runtime resources live in AppModel, so deletion announces before model /// references are removed and the controller can stop listeners cleanly. var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)? - var didPersistCore: (() -> Void)? - var didRecordVaultChanges: ((VaultMutationSet) -> Void)? + var didPersistCore: (() -> Void)? { + get { vault.didPersist } + set { vault.didPersist = newValue } + } + var didRecordVaultChanges: ((VaultMutationSet) -> Void)? { + get { vault.didRecordChanges } + set { vault.didRecordChanges = newValue } + } func reportStorageError(_ error: any Error) { storageError = error.localizedDescription @@ -74,11 +80,16 @@ final class HostLibraryModel { self.portForwardings = portForwardings self.sftpTransfers = sftpTransfers self.knownHosts = knownHosts - self.persistence = persistence + self.vault = VaultStore(persistence: persistence) self.keychain = secrets - guard let persistence else { return } do { - let loaded = try persistence.load() + let loaded = try vault.load( + fallback: VaultSnapshot( + hosts: hosts, groups: groups, identities: identities, keys: keys, + knownHosts: knownHosts, snippets: snippets, + snippetPackages: snippetPackages, portForwardings: portForwardings + ) + ) self.hosts = loaded.hosts self.groups = loaded.groups self.identities = loaded.identities.sorted { @@ -1567,19 +1578,13 @@ final class HostLibraryModel { @discardableResult private func persistCore() -> Bool { - guard let persistence else { - didPersistCore?() - return true - } do { - let mutations = try persistence.save(VaultSnapshot( + try vault.save(VaultSnapshot( hosts: hosts, groups: groups, identities: identities, keys: keys, knownHosts: knownHosts, snippets: snippets, snippetPackages: snippetPackages, portForwardings: portForwardings )) storageError = nil - didPersistCore?() - didRecordVaultChanges?(mutations) return true } catch { storageError = error.localizedDescription @@ -1607,7 +1612,7 @@ final class HostLibraryModel { hosts.contains { $0.id == value.hostID } } storageError = nil - didPersistCore?() + vault.didApplyRemoteSnapshot() } } diff --git a/Termind/Application/FeatureModels/SnippetLibraryModel.swift b/Termind/Application/FeatureModels/SnippetLibraryModel.swift index 478432c2..ccff9f59 100644 --- a/Termind/Application/FeatureModels/SnippetLibraryModel.swift +++ b/Termind/Application/FeatureModels/SnippetLibraryModel.swift @@ -3,7 +3,7 @@ import Foundation /// The Snippets feature's deliberately narrow view of the shared vault. @Observable final class SnippetLibraryModel { - let library: HostLibraryModel + private let library: HostLibraryModel init(library: HostLibraryModel) { self.library = library diff --git a/Termind/Application/FeatureModels/TransferLibraryModel.swift b/Termind/Application/FeatureModels/TransferLibraryModel.swift index 6369221f..651a6da6 100644 --- a/Termind/Application/FeatureModels/TransferLibraryModel.swift +++ b/Termind/Application/FeatureModels/TransferLibraryModel.swift @@ -3,9 +3,70 @@ import Foundation /// Saved-vault access exposed to transfer presentation and session entry points. @Observable final class TransferLibraryModel { - let library: HostLibraryModel + private let library: HostLibraryModel init(library: HostLibraryModel) { self.library = library } } + +extension TransferLibraryModel: RemoteSessionHostStore { + func host(_ id: Host.ID) -> Host? { library.host(id) } + func identity(_ id: Identity.ID) -> Identity? { library.identity(id) } + func identity(for host: Host) -> Identity? { library.identity(for: host) } + func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) } + func knownHosts(for host: Host) -> [KnownHostEntry] { library.knownHosts(for: host) } + func route(for host: Host) -> [Host] { library.route(for: host) } + func expectedHostKeys( + for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> Set { + try library.expectedHostKeys(for: host, temporaryHostKeys: temporaryHostKeys) + } + func sshConnectionConfiguration( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> SSHConnectionConfiguration { + try library.sshConnectionConfiguration( + for: host, submissions: submissions, temporaryHostKeys: temporaryHostKeys + ) + } + func sshJumpConfigurations( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> [SSHRouteHopConfiguration] { + try library.sshJumpConfigurations( + for: host, submissions: submissions, temporaryHostKeys: temporaryHostKeys + ) + } + func transportProxy(for host: Host) throws -> SSHTransportProxy? { + try library.transportProxy(for: host) + } + func sshCredentials(for identity: Identity) -> SSHCredentialResolution { + library.sshCredentials(for: identity) + } + func passwordCredential(for identity: Identity) throws -> String? { + try library.passwordCredential(for: identity) + } + func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { + try library.keyCredential(for: key) + } + func setConnectionStatus( + _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID + ) { + library.setConnectionStatus(status, connectionID: connectionID, hostID: hostID) + } + func setSSHSessionStatus( + _ status: ConnectionStatus, sshSessionID: UUID, hostID: Host.ID + ) { + library.setSSHSessionStatus(status, sshSessionID: sshSessionID, hostID: hostID) + } + func trust(_ entry: KnownHostEntry) { library.trust(entry) } + func markKnownHostMismatched(hostname: String, port: Int) { + library.markKnownHostMismatched(hostname: hostname, port: port) + } + @discardableResult func upsert(_ identity: Identity) -> Bool { library.upsert(identity) } + func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { + try library.upsert(identity, passwordUpdate: passwordUpdate) + } + func upsert(_ host: Host) { library.upsert(host) } +} diff --git a/Termind/Application/Repositories/VaultStore.swift b/Termind/Application/Repositories/VaultStore.swift new file mode 100644 index 00000000..ceddd800 --- /dev/null +++ b/Termind/Application/Repositories/VaultStore.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Persistence boundary shared by the Vault feature models. It keeps concrete +/// storage and sync notifications out of aggregate models while preserving the +/// existing `VaultSnapshot` transaction and on-disk schema. +@MainActor +final class VaultStore { + private let persistence: (any VaultPersisting)? + var didPersist: (() -> Void)? + var didRecordChanges: ((VaultMutationSet) -> Void)? + + init(persistence: (any VaultPersisting)? = nil) { + self.persistence = persistence + } + + func load(fallback: VaultSnapshot) throws -> VaultSnapshot { + try persistence?.load() ?? fallback + } + + func save(_ snapshot: VaultSnapshot) throws { + if let persistence { + let mutations = try persistence.save(snapshot) + didRecordChanges?(mutations) + } + didPersist?() + } + + /// A remote projection is already persisted by the sync engine. Aggregate + /// models only need to refresh repository projections after accepting it. + func didApplyRemoteSnapshot() { + didPersist?() + } +} diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index f81915fc..81761cd6 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -598,7 +598,7 @@ extension View { } enum Motion { - static let tabSwitch = Animation.easeOut(duration: 0.12) + static let workspaceSwitch = Animation.easeOut(duration: 0.12) static let panel = Animation.smooth(duration: 0.22) static let statusPulse = Animation.easeInOut(duration: 0.9).repeatForever(autoreverses: true) diff --git a/Termind/Design/StatusIndicator.swift b/Termind/Design/StatusIndicator.swift index 59a1a805..26fd8a90 100644 --- a/Termind/Design/StatusIndicator.swift +++ b/Termind/Design/StatusIndicator.swift @@ -2,7 +2,7 @@ import SwiftUI /// The shared visual vocabulary for connection state. /// -/// Sidebar rows, tab items, and the status bar all render the same dot, so a +/// Sidebar rows, workspace items, and the status bar all render the same dot, so a /// color means one thing everywhere in the window. extension ConnectionStatus { var tint: Color { @@ -26,7 +26,7 @@ extension ConnectionStatus { } /// A connection-state dot. Pulses while a connection is being established, and -/// turns blue when a background tab has produced output the user has not seen. +/// turns blue when a background workspace has produced output the user has not seen. struct StatusDot: View { let status: ConnectionStatus var hasUnread: Bool = false diff --git a/Termind/Domain/Agent/AgentTypes.swift b/Termind/Domain/Agent/AgentTypes.swift index 8b7fb096..b6674a6d 100644 --- a/Termind/Domain/Agent/AgentTypes.swift +++ b/Termind/Domain/Agent/AgentTypes.swift @@ -741,7 +741,7 @@ nonisolated struct AgentRunRequest: Sendable { var priorMessages: [AgentTranscriptMessage] /// Where the run is happening, as the surface that started it understood it. /// Per-run rather than per-conversation: the same chat continued from the - /// Assistant tab has no focused pane, and must not claim one. + /// Assistant destination has no focused pane, and must not claim one. var sessionContext: SessionContext init( diff --git a/Termind/Domain/Sessions/SessionContext.swift b/Termind/Domain/Sessions/SessionContext.swift index c53a0fb9..1cf59832 100644 --- a/Termind/Domain/Sessions/SessionContext.swift +++ b/Termind/Domain/Sessions/SessionContext.swift @@ -5,8 +5,8 @@ import Foundation /// This belongs to the *surface*, not to a conversation. A conversation that /// owns its session context carries it wherever it is opened — which is how the /// Workspace sidebar's host and directory ended up on display in the fixed -/// Assistant tab, where no session exists to describe. Holding it on -/// `WindowSidebarState` instead means the Assistant tab has nothing to show and +/// Assistant destination, where no session exists to describe. Holding it on +/// `WindowSidebarState` instead means the Assistant destination has nothing to show and /// nothing to send, structurally rather than by remembering to clear it. /// /// Everything here is uploaded on send, which is the whole point: the chips are diff --git a/Termind/Domain/Transfers/SFTPTransfer.swift b/Termind/Domain/Transfers/SFTPTransfer.swift index 7c37d80d..0bda3ed1 100644 --- a/Termind/Domain/Transfers/SFTPTransfer.swift +++ b/Termind/Domain/Transfers/SFTPTransfer.swift @@ -2,8 +2,8 @@ import Foundation /// One queued or running SFTP transfer. /// -/// The queue is app-wide rather than per-SFTP-tab: a copy started from one -/// browser should keep running when that tab is closed. The browser session only +/// The queue is app-wide rather than per-SFTP workspace: a copy started from one +/// browser should keep running when that workspace is closed. The browser session only /// enqueues work; a queue region inside an SFTP session can report app-wide work /// without making navigation state global. nonisolated struct SFTPTransfer: Identifiable, Hashable, Sendable { diff --git a/Termind/Features/AI/CommandCardView.swift b/Termind/Features/AI/CommandCardView.swift index cd924ce0..83dfa309 100644 --- a/Termind/Features/AI/CommandCardView.swift +++ b/Termind/Features/AI/CommandCardView.swift @@ -152,9 +152,9 @@ struct CommandCardView: View { /// Return key is the only thing that ever submits a command they did not /// explicitly choose to run. private func insert(run: Bool) { - guard let tab = window.selectedWorkspace, - let sshSession = tab.focusedPane.sshSession ?? tab.broadcastTargets.first + guard let workspace = window.selectedWorkspace, + let sshSession = workspace.focusedPane.sshSession ?? workspace.broadcastTargets.first else { return } - tab.deliver(command.command, from: sshSession, submit: run) + workspace.deliver(command.command, from: sshSession, submit: run) } } diff --git a/Termind/Features/AI/ConversationComposerState.swift b/Termind/Features/AI/ConversationComposerState.swift new file mode 100644 index 00000000..14a63efc --- /dev/null +++ b/Termind/Features/AI/ConversationComposerState.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Draft and attachment state owned by the composer rather than the runtime or +/// transcript. A conversation exposes compatibility accessors while views move +/// toward observing this focused state directly. +@Observable +final class ConversationComposerState { + var attachments: [ContextChip] = [] + var pendingAttachments: [PendingContextAttachment] = [] + var attachmentImportError: String? + var draft = "" +} diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 9cce153c..a5c96a03 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -502,7 +502,7 @@ nonisolated enum ChatPermissionMode: String, Codable, Sendable, CaseIterable, Id /// It deliberately knows nothing about a session. Where the conversation is /// *about* a host and a directory, that comes from the surface showing it — /// `WindowSidebarState.sessionContext` — because a conversation that owned its -/// own session context carried it into the Assistant tab, which has no session +/// own session context carried it into the Assistant destination, which has no session /// to describe. @Observable final class ConversationModel: Identifiable { @@ -514,13 +514,25 @@ final class ConversationModel: Identifiable { var sortIndex: Double var archivedAt: Date? var messages: [ChatMessage] = [] - /// What the user attached by hand, each carrying its own payload. Consumed - /// by the next user turn: an attachment belongs to one message, and once - /// that message is sent the content is in the transcript already. - var attachments: [ContextChip] = [] - var pendingAttachments: [PendingContextAttachment] = [] - var attachmentImportError: String? - var draft: String = "" + let composer = ConversationComposerState() + /// Compatibility accessors keep call sites stable while composer views move + /// to the focused state object. + var attachments: [ContextChip] { + get { composer.attachments } + set { composer.attachments = newValue } + } + var pendingAttachments: [PendingContextAttachment] { + get { composer.pendingAttachments } + set { composer.pendingAttachments = newValue } + } + var attachmentImportError: String? { + get { composer.attachmentImportError } + set { composer.attachmentImportError = newValue } + } + var draft: String { + get { composer.draft } + set { composer.draft = newValue } + } private(set) var isStreaming: Bool = false /// What the transcript shows. Derived — see `refreshRunState`. private(set) var runState: AgentRunState = .idle diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index cb4ea5d4..fb3285ab 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -2,7 +2,7 @@ import SwiftUI /// A conversation rendered as chips + transcript + composer. /// -/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only +/// Shared by the session Assistant sidebar and full Assistant destination so the two can never drift; only /// the padding and the provider header differ. struct ConversationView: View { enum Region { diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index b9a52a72..de95c958 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -97,7 +97,7 @@ struct TextViewMarkedTextObserver: NSViewRepresentable { /// /// Two sources, deliberately. Host and directory are pinned and come from the /// *surface*; everything the user added by hand comes from the conversation and -/// can be taken back off. So a composer with no session — the Assistant tab, +/// can be taken back off. So a composer with no session — the Assistant destination, /// which is handed `.none` — cannot show, or send, a host and directory it does /// not have. struct ContextChipsView: View { @@ -387,12 +387,12 @@ struct ComposerView: View { let conversation: ConversationModel var prompt: LocalizedStringKey = "Ask anything…" var surfaceStyle: AssistantInteractionSurfaceStyle = .standard - /// The full Assistant tab shares one centered column with its transcript. + /// The full Assistant destination shares one centered column with its transcript. /// `nil` keeps compact/sidebar composers fluid. var maxContentWidth: CGFloat? = nil /// What the surface showing this composer knows about the focused pane. /// Defaulting to `.none` is what makes a composer outside a Workspace — the - /// Assistant tab — session-free by construction rather than by remembering. + /// Assistant destination — session-free by construction rather than by remembering. var sessionContext: SessionContext = .none /// Only the Workspace sidebar gives its agent a command-card tool. var includesTerminalSuggestion = false diff --git a/Termind/Features/AI/MarkdownCodeBlockView.swift b/Termind/Features/AI/MarkdownCodeBlockView.swift index 2b73fba7..ea90d2db 100644 --- a/Termind/Features/AI/MarkdownCodeBlockView.swift +++ b/Termind/Features/AI/MarkdownCodeBlockView.swift @@ -33,12 +33,12 @@ private struct ShellCodeBlockView: View { /// The shell this card would reach. /// - /// `selectedWorkspace` is `nil` whenever the pinned Assistant tab is showing, and + /// `selectedWorkspace` is `nil` whenever the pinned Assistant destination is showing, and /// that is exactly where the full-width transcript lives — so the buttons /// disable there instead of looking live and doing nothing. private var target: SSHSessionModel? { - guard let tab = window.selectedWorkspace else { return nil } - return tab.focusedPane.sshSession ?? tab.broadcastTargets.first + guard let workspace = window.selectedWorkspace else { return nil } + return workspace.focusedPane.sshSession ?? workspace.broadcastTargets.first } var body: some View { @@ -110,8 +110,8 @@ private struct ShellCodeBlockView: View { /// every other programmatic route to a prompt. `submit: false` writes /// without the newline; the user's own Return is what runs it. private func deliver(submit: Bool) { - guard let tab = window.selectedWorkspace, let origin = target else { return } - tab.deliver(command, from: origin, submit: submit) + guard let workspace = window.selectedWorkspace, let origin = target else { return } + workspace.deliver(command, from: origin, submit: submit) } } diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index cfbb5bf5..5371c370 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -75,7 +75,7 @@ struct AssistantSidebarView: View { /// The global Assistant contained entirely in a Workspace sidebar. /// -/// Its route is deliberately separate from the fixed Assistant tab's selection: +/// Its route is deliberately separate from the fixed Assistant destination's selection: /// opening a chat here never takes the user's terminal out of the foreground. struct AssistantPanelView: View { @Environment(WindowModel.self) private var window @@ -1279,7 +1279,7 @@ struct AssistantConversationRow: View { } } -/// The second pinned tab's main conversation surface. +/// The second pinned destination's main conversation surface. /// /// It passes no session context, and that is the point: this tab is not attached /// to a Workspace, so there is no focused pane whose host and directory it could diff --git a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift deleted file mode 100644 index 6b942995..00000000 --- a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -protocol ContextPanelDetailProvider { - var kind: String { get } - var availableIDs: Set { get } -} - -struct AnyContextPanelDetailProvider: ContextPanelDetailProvider { - let kind: String - private let available: () -> Set - - init(_ provider: P) { - kind = provider.kind - available = { provider.availableIDs } - } - - var availableIDs: Set { available() } -} - -struct ContextPanelDetailCatalog { - private let providers: [AnyContextPanelDetailProvider] - - init(_ providers: [AnyContextPanelDetailProvider]) { - self.providers = providers - assert(Set(providers.map(\.kind)).count == providers.count) - } - - var availableIDs: Set { - providers.reduce(into: []) { $0.formUnion($1.availableIDs) } - } - - static func standard(store: HostLibraryModel) -> Self { - Self([ - AnyContextPanelDetailProvider(HostContextPanelDetailProvider(store: store)), - AnyContextPanelDetailProvider(KeyContextPanelDetailProvider(store: store)), - AnyContextPanelDetailProvider(IdentityContextPanelDetailProvider(store: store)), - AnyContextPanelDetailProvider(SnippetContextPanelDetailProvider(store: store)), - ]) - } -} - -private struct HostContextPanelDetailProvider: ContextPanelDetailProvider { - let store: HostLibraryModel - let kind = ContextPanelDetailID.Kind.host - var availableIDs: Set { Set(store.hosts.map { .host($0.id) }) } -} - -private struct KeyContextPanelDetailProvider: ContextPanelDetailProvider { - let store: HostLibraryModel - let kind = ContextPanelDetailID.Kind.key - var availableIDs: Set { Set(store.keys.map { .key($0.id) }) } -} - -private struct IdentityContextPanelDetailProvider: ContextPanelDetailProvider { - let store: HostLibraryModel - let kind = ContextPanelDetailID.Kind.identity - var availableIDs: Set { - Set(store.identities.map { .identity($0.id) }) - } -} - -private struct SnippetContextPanelDetailProvider: ContextPanelDetailProvider { - let store: HostLibraryModel - let kind = ContextPanelDetailID.Kind.snippet - var availableIDs: Set { - Set(store.snippets.map { .snippet($0.id) }) - } -} diff --git a/Termind/Features/HostEditor/HostEditorView.swift b/Termind/Features/HostEditor/HostEditorView.swift index 303e1036..bd58282b 100644 --- a/Termind/Features/HostEditor/HostEditorView.swift +++ b/Termind/Features/HostEditor/HostEditorView.swift @@ -2,7 +2,7 @@ import SwiftUI /// Add or edit a host. /// -/// A route inside Vault rather than a dynamic tab or sheet. It edits durable +/// A route inside Vault rather than a dynamic workspace or sheet. It edits durable /// connection data without entering SSH/SFTP Workspace lifecycle state. struct HostEditorView: View { private enum Route: Hashable { diff --git a/Termind/Features/Hosts/HostActions.swift b/Termind/Features/Hosts/HostActions.swift index aefdd7fb..9bb079d1 100644 --- a/Termind/Features/Hosts/HostActions.swift +++ b/Termind/Features/Hosts/HostActions.swift @@ -6,7 +6,7 @@ import SwiftUI /// Everything you can do to a host, in one menu. /// -/// Shared by Vault host surfaces, the Welcome page, and the tab bar's +/// Shared by Vault host surfaces, the Welcome page, and the workspace list's /// recent-hosts menu — places that would otherwise drift into slightly /// different sets of verbs. struct HostContextMenu: View { @@ -34,9 +34,9 @@ struct HostContextMenu: View { } var body: some View { - // No "Connect in Split": a Workspace is built by combining tabs that are + // No "Connect in Split": a Workspace is built by combining workspaces that are // already open, so connecting never decides a layout for you. Connect, - // then drag the new tab onto the one you want it beside. + // then drag the new workspace onto the one you want it beside. if hosts.count == 1 { Button("Quick Connect") { app.open(hosts, as: .sshSession, in: window) } } else { diff --git a/Termind/Features/Hosts/HostsPageView.swift b/Termind/Features/Hosts/HostsPageView.swift index 03c2524e..17a5dedb 100644 --- a/Termind/Features/Hosts/HostsPageView.swift +++ b/Termind/Features/Hosts/HostsPageView.swift @@ -40,7 +40,7 @@ final class HostsPageState { /// problem. Groups are the sections, so the hierarchy the user built in the host /// editor is the hierarchy they see here. /// -/// Selecting cards updates the Hosts page's ordered ContextPanel selection, which +/// Selecting cards updates the Hosts page's ordered Vault selection, which /// the Details tab and this page's own Connect and Edit buttons read. Double-click /// connects. Nothing outside this page acts on that selection — ⌘T raises the /// host chooser rather than reusing whatever is highlighted here. @@ -68,10 +68,10 @@ struct HostsPageView: ManagedCardPage { } /// A plain click replaces the window-scoped selection; Shift-click and a /// background marquee extend it. Keeping it on `WindowModel` lets the page - /// restore the same cards when its tab becomes current again. + /// restore the same cards when its workspace becomes current again. private var hostSelection: Set { Set( - window.detailSelection(for: .hosts).compactMap { detail in + window.vaultSelection(for: .hosts).compactMap { detail in detail.hostID }) } @@ -305,7 +305,7 @@ struct HostsPageView: ManagedCardPage { pruneHostSelection() if state.selectedGroupID == group.id { state.selectedGroupID = nil } - window.pruneDetailSelections(in: app.hostLibrary) + window.pruneVaultSelections(in: app.hostLibrary) } private func deletionMessage(for group: HostGroup) -> String { @@ -330,12 +330,12 @@ struct HostsPageView: ManagedCardPage { private func setHostSelection(_ ids: Set) { let valid = ids.intersection(Set(app.hostLibrary.hosts.map(\.id))) state.selectedGroupID = nil - let ordered = visibleHosts.filter { valid.contains($0.id) }.map { ContextPanelDetailID.host($0.id) } - window.setDetailSelection(ordered, for: .hosts) + let ordered = visibleHosts.filter { valid.contains($0.id) }.map { VaultSelectionID.host($0.id) } + window.setVaultSelection(ordered, for: .hosts) } private func selectGroup(_ id: HostGroup.ID) { - window.clearDetailSelection(for: .hosts) + window.clearVaultSelection(for: .hosts) state.selectedGroupID = id } diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index 3dfec0e6..7b46c079 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -30,7 +30,7 @@ struct KeychainPageView: ManagedCardPage { case key(SSHKeyRef) case identity(Identity) - var id: ContextPanelDetailID { + var id: VaultSelectionID { switch self { case .key(let key): .key(key.id) case .identity(let identity): .identity(identity.id) @@ -59,8 +59,8 @@ struct KeychainPageView: ManagedCardPage { private var keys: [SSHKeyRef] { app.credentials.searchKeys(state.query) } private var identities: [Identity] { app.credentials.searchIdentities(state.query) } - private var selection: Set { - Set(window.detailSelection(for: .keychain)) + private var selection: Set { + Set(window.vaultSelection(for: .keychain)) } private var isEmpty: Bool { @@ -82,11 +82,11 @@ struct KeychainPageView: ManagedCardPage { ] } - var managedSelection: ManagedCardSelection { + var managedSelection: ManagedCardSelection { ManagedCardSelection(selected: { selection }, update: setSelection) } - var managedCapabilities: ManagedCardCapabilities { + var managedCapabilities: ManagedCardCapabilities { ManagedCardCapabilities( selection: .multiple, marqueeSelection: true, @@ -200,13 +200,13 @@ struct KeychainPageView: ManagedCardPage { // MARK: - Selection - private func setSelection(_ selected: Set) { - let ordered: [ContextPanelDetailID] = + private func setSelection(_ selected: Set) { + let ordered: [VaultSelectionID] = keys.compactMap { selected.contains(.key($0.id)) ? .key($0.id) : nil } + identities.compactMap { selected.contains(.identity($0.id)) ? .identity($0.id) : nil } - window.setDetailSelection(ordered, for: .keychain) + window.setVaultSelection(ordered, for: .keychain) } private func requestExport(_ keys: [SSHKeyRef]) { @@ -231,7 +231,7 @@ struct KeychainPageView: ManagedCardPage { state.errorMessage = error.localizedDescription } } - window.pruneDetailSelections(in: app.credentials) + window.pruneVaultSelections(in: app.credentials) } func managedEmptyState() -> some View { @@ -265,7 +265,7 @@ struct KeychainPageView: ManagedCardPage { KeychainPagePresentation( state: state, actions: KeychainPagePresentation.Actions( - pruneSelection: { window.pruneDetailSelections(in: app.credentials) } + pruneSelection: { window.pruneVaultSelections(in: app.credentials) } ) ) } @@ -331,7 +331,7 @@ struct KeychainImportDetail: View { SSHPrivateKeySecret(privateKey: privateKey) ) ) - window.selectDetail(.key(key.id), on: .keychain) + window.selectVaultItem(.key(key.id), on: .keychain) return key } } @@ -659,7 +659,7 @@ struct KeychainGenerateDetail: View { ) ) } - window.selectDetail(.key(key.id), on: .keychain) + window.selectVaultItem(.key(key.id), on: .keychain) return key } } @@ -888,7 +888,7 @@ struct KeychainEditDetail: View { SSHPrivateKeySecret(privateKey: privateKey) ) ) - window.selectDetail(.key(updated.id), on: .keychain) + window.selectVaultItem(.key(updated.id), on: .keychain) return updated } @@ -896,7 +896,7 @@ struct KeychainEditDetail: View { var updated = original updated.label = name try app.credentials.upsert(updated) - window.selectDetail(.key(updated.id), on: .keychain) + window.selectVaultItem(.key(updated.id), on: .keychain) } } @@ -1400,7 +1400,7 @@ private final class ExportHostHostingRow: ListRowView { // MARK: - Key card -/// The singular actions for one key, shared by its card and ContextPanel detail. +/// The singular actions for one key, shared by its card and editor surfaces. /// Card actions never resolve secret material; Edit is the explicit boundary /// that loads a software key from Keychain. struct KeyObjectActions: View { @@ -1441,7 +1441,7 @@ private struct KeyCard: View { var body: some View { CardTile( - selectionID: ContextPanelDetailID.key(key.id), + selectionID: VaultSelectionID.key(key.id), isSelected: isSelected, select: select, extendSelection: extendSelection @@ -1572,7 +1572,7 @@ private struct IdentityCard: View { var body: some View { CardTile( - selectionID: ContextPanelDetailID.identity(identity.id), + selectionID: VaultSelectionID.identity(identity.id), isSelected: isSelected, select: select, extendSelection: extendSelection diff --git a/Termind/Features/SFTP/SFTPBrowserView.swift b/Termind/Features/SFTP/SFTPBrowserView.swift index 57446a6f..067e6a25 100644 --- a/Termind/Features/SFTP/SFTPBrowserView.swift +++ b/Termind/Features/SFTP/SFTPBrowserView.swift @@ -648,7 +648,7 @@ struct SFTPBrowserView: View { } } .animation( - Motion.tabSwitch, + Motion.workspaceSwitch, value: isRemoteDropTargeted || isExternalFileDropTargeted ) .onChange(of: externalFileDrop?.id) { diff --git a/Termind/Features/SFTP/SFTPSessionModel.swift b/Termind/Features/SFTP/SFTPSessionModel.swift index 3d23a3ae..c01150f2 100644 --- a/Termind/Features/SFTP/SFTPSessionModel.swift +++ b/Termind/Features/SFTP/SFTPSessionModel.swift @@ -228,7 +228,8 @@ final class SFTPSessionModel: Identifiable { var pendingHostKey: SSHHostKeyPrompt? private var pendingHostKeyHostID: Host.ID? var pendingAuthentication: SSHAuthenticationPrompt? - let connectionJourney = SSHConnectionJourney() + let connection = RemoteSessionConnectionController() + var connectionJourney: SSHConnectionJourney { connection.journey } var pendingBatch: PreparedBatch? var pendingRemoteCopyBatch: PreparedRemoteCopyBatch? var operationError: String? @@ -243,12 +244,12 @@ final class SFTPSessionModel: Identifiable { private var suppliedAuthentications: [Host.ID: SSHAuthenticationSubmission] = [:] private var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] private var connectionAttemptID: UUID? - private let reconnectController = SessionReconnectController() + private var reconnectController: SessionReconnectController { connection.reconnect } private var hasConnectedOnce = false private var isRecoveringConnection = false private var operationCancellation: TransferCancellationToken? weak var navigationRouter: (any SFTPSessionNavigationRouting)? - private weak var statusStore: HostLibraryModel? + private weak var statusStore: (any RemoteSessionHostStore)? private weak var transferCoordinator: SFTPTransferCoordinator? weak var historyStore: SFTPHistoryModel? private var didResolveInitialPath: Bool @@ -276,7 +277,7 @@ final class SFTPSessionModel: Identifiable { if client != nil { hasConnectedOnce = true } } - var reconnectState: ReconnectState? { reconnectController.state } + var reconnectState: ReconnectState? { connection.reconnectState } var allowsRemoteMutations: Bool { status == .connected } var canGoBack: Bool { !backHistory.isEmpty } @@ -321,15 +322,15 @@ final class SFTPSessionModel: Identifiable { return configuration.map(SFTPTransferConnection.saved) } - func connect(using store: HostLibraryModel) { + func connect(using store: any RemoteSessionHostStore) { startConnection(using: store) } func connect(using transfers: TransferLibraryModel) { - connect(using: transfers.library) + connect(using: transfers as any RemoteSessionHostStore) } - private func startConnection(using store: HostLibraryModel) { + private func startConnection(using store: any RemoteSessionHostStore) { let reconnecting = hasConnectedOnce && isRecoveringConnection bindStatus(to: store) connectionTask?.cancel() @@ -494,7 +495,7 @@ final class SFTPSessionModel: Identifiable { } } - func bindStatus(to store: HostLibraryModel) { + func bindStatus(to store: any RemoteSessionHostStore) { statusStore = store store.setConnectionStatus(status, connectionID: id, hostID: hostID) } @@ -562,7 +563,7 @@ final class SFTPSessionModel: Identifiable { didResolveInitialPath = true } - func trustAndConnect(_ observation: SSHHostKeyObservation, using store: HostLibraryModel) { + func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) { store.trust( KnownHostEntry( hostname: observation.hostname, port: observation.port, @@ -581,11 +582,11 @@ final class SFTPSessionModel: Identifiable { func trustAndConnect( _ observation: SSHHostKeyObservation, using transfers: TransferLibraryModel ) { - trustAndConnect(observation, using: transfers.library) + trustAndConnect(observation, using: transfers as any RemoteSessionHostStore) } func continueWithTemporaryHostKey( - _ observation: SSHHostKeyObservation, using store: HostLibraryModel + _ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore ) { guard let hostID = pendingHostKeyHostID else { return } temporaryHostKeys[hostID] = observation.identity @@ -598,12 +599,14 @@ final class SFTPSessionModel: Identifiable { func continueWithTemporaryHostKey( _ observation: SSHHostKeyObservation, using transfers: TransferLibraryModel ) { - continueWithTemporaryHostKey(observation, using: transfers.library) + continueWithTemporaryHostKey( + observation, using: transfers as any RemoteSessionHostStore + ) } func continueAuthentication( identityID: Identity.ID?, username: String, password: String?, passphrase: String?, - keyID: SSHKeyRef.ID?, save: Bool, using store: HostLibraryModel + keyID: SSHKeyRef.ID?, save: Bool, using store: any RemoteSessionHostStore ) { guard let prompt = pendingAuthentication, var host = store.host(prompt.host.id) @@ -700,7 +703,8 @@ final class SFTPSessionModel: Identifiable { ) { continueAuthentication( identityID: identityID, username: username, password: password, - passphrase: passphrase, keyID: keyID, save: save, using: transfers.library + passphrase: passphrase, keyID: keyID, save: save, + using: transfers as any RemoteSessionHostStore ) } @@ -1396,7 +1400,7 @@ final class SFTPSessionModel: Identifiable { return copy } - private func discoverHostKey(host: Host, store: HostLibraryModel) async { + private func discoverHostKey(host: Host, store: any RemoteSessionHostStore) async { do { let observation = try await SSHSessionActor.discoverHostKey( hostID: host.id, host: host.host, port: host.port, @@ -1436,7 +1440,7 @@ final class SFTPSessionModel: Identifiable { } private func requestAuthentication( - host: Host, store: HostLibraryModel, failure: String? = nil, + host: Host, store: any RemoteSessionHostStore, failure: String? = nil, mode: SSHAuthenticationPrompt.Mode = .localIdentity, requiresPassphrase: Bool? = nil, preserveSubmission: Bool = false @@ -1491,7 +1495,7 @@ final class SFTPSessionModel: Identifiable { scheduleReconnect(after: message, using: store) } - private func scheduleReconnect(after message: String, using store: HostLibraryModel) { + private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) { isRecoveringConnection = true connectionAttemptID = nil connectionTask?.cancel() @@ -1513,7 +1517,7 @@ final class SFTPSessionModel: Identifiable { Task { await client?.disconnect() } } - private func presentReconnectJourney(using store: HostLibraryModel) { + private func presentReconnectJourney(using store: any RemoteSessionHostStore) { guard let host = store.host(hostID) else { return } connectionJourney.begin( host: host, route: store.route(for: host), purpose: .sftpSession, diff --git a/Termind/Features/Shell/WindowTitle.swift b/Termind/Features/Shell/WindowTitle.swift index a5bb210f..495c31f1 100644 --- a/Termind/Features/Shell/WindowTitle.swift +++ b/Termind/Features/Shell/WindowTitle.swift @@ -3,7 +3,7 @@ import Foundation /// What the titlebar calls whatever is in front. /// /// A catalog of providers rather than a switch inside `WindowModel`, for the -/// same reason `ContextPanelDetailCatalog` exists: the window should not have to +/// the window should not have to /// know that a Workspace is named after the host it connects to, or that the /// Assistant is named after a conversation rather than after itself. Each /// feature answers for its own destination and returns nil for the others; the diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/Sidebar/WindowSidebarView.swift similarity index 93% rename from Termind/Features/ContextPanel/ContextPanelView.swift rename to Termind/Features/Sidebar/WindowSidebarView.swift index 2e267476..bf870a40 100644 --- a/Termind/Features/ContextPanel/ContextPanelView.swift +++ b/Termind/Features/Sidebar/WindowSidebarView.swift @@ -5,15 +5,12 @@ import SwiftUI /// The window's one left-hand column. A glass mode rail switches the Navigator /// and three Workspace tools in place without the square selection segments of /// `NSSegmentedControl`. -struct SidebarView: View { - let detailCatalog: ContextPanelDetailCatalog +struct WindowSidebarView: View { @Environment(WindowModel.self) private var window @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - let availableDetails = detailCatalog.availableIDs - VStack(spacing: 0) { modeSelector .padding(.horizontal, Metrics.Space.m) @@ -21,11 +18,7 @@ struct SidebarView: View { sidebarContent } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onChange(of: availableDetails, initial: true) { - window.sidebar.pruneSelections(available: availableDetails) - } .onChange(of: focusedPaneID, initial: true) { - window.sidebar.focusedPaneDidChange(to: window.selectedWorkspace?.focusedPane) window.reconcileSidebarMode() } .onChange(of: window.selectedDestination, initial: true) { @@ -127,14 +120,14 @@ struct SidebarView: View { case .assistant: AssistantPanelView() case .snippets: - SSHSessionSnippetsSidebar() + SnippetsSidebarView() case .history: - AdaptiveHistorySidebar() + HistorySidebarView() } } } -nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable { +nonisolated struct SidebarListItem: Identifiable, Hashable, Sendable { nonisolated enum SectionID: Hashable, Sendable { case package(UUID) case ungrouped @@ -222,14 +215,14 @@ nonisolated struct SnippetSidebarExpansionState: Equatable, Sendable { collapsedSectionIDs.sorted().joined(separator: "\n") } - func isExpanded(_ section: ContextSidebarListItem.SectionID) -> Bool { + func isExpanded(_ section: SidebarListItem.SectionID) -> Bool { guard let id = section.snippetExpansionID else { return true } return !collapsedSectionIDs.contains(id) } mutating func setExpanded( _ expanded: Bool, - for section: ContextSidebarListItem.SectionID + for section: SidebarListItem.SectionID ) { guard let id = section.snippetExpansionID else { return } if expanded { @@ -241,7 +234,7 @@ nonisolated struct SnippetSidebarExpansionState: Equatable, Sendable { } nonisolated struct SnippetSidebarGroup: Sendable { - let id: ContextSidebarListItem.SectionID + let id: SidebarListItem.SectionID let title: String let snippets: [Snippet] } @@ -251,20 +244,20 @@ nonisolated enum SnippetSidebarListBuilder { static func groupedItems( _ groups: [SnippetSidebarGroup], expansion: SnippetSidebarExpansionState - ) -> [ContextSidebarListItem] { - var items: [ContextSidebarListItem] = [] + ) -> [SidebarListItem] { + var items: [SidebarListItem] = [] for group in groups where !group.snippets.isEmpty { items.append(.section(group.id, title: group.title, count: group.snippets.count)) if expansion.isExpanded(group.id) { - items.append(contentsOf: group.snippets.map(ContextSidebarListItem.snippet)) + items.append(contentsOf: group.snippets.map(SidebarListItem.snippet)) } } return items } - static func searchItems(_ snippets: [Snippet]) -> [ContextSidebarListItem] { + static func searchItems(_ snippets: [Snippet]) -> [SidebarListItem] { [.section(.results, title: "Results", count: snippets.count)] - + snippets.map(ContextSidebarListItem.snippet) + + snippets.map(SidebarListItem.snippet) } } @@ -274,7 +267,7 @@ nonisolated enum SnippetSidebarListBuilder { /// so every section label in the column starts on the same vertical line — /// this one used to sit 2pt further right for no reason anyone could see except /// by putting the two modes side by side. -private func contextSidebarSectionTitle(_ title: String) -> some View { +private func sidebarSectionTitle(_ title: String) -> some View { Text(title) .termindSidebarSectionLabel() .frame(maxWidth: .infinity, alignment: .leading) @@ -287,9 +280,9 @@ private func contextSidebarSectionTitle(_ title: String) -> some View { /// A Snippet package heading with the same count, chevron and hit region as an /// Assistant section. It owns the storage read so changing expansion never -/// changes the `ContextSidebarListItem` representing this row. +/// changes the `SidebarListItem` representing this row. private struct SnippetSidebarSectionHeaderRow: View { - let section: ContextSidebarListItem.SectionID + let section: SidebarListItem.SectionID let title: String let count: Int @@ -337,12 +330,12 @@ private struct SnippetSidebarSectionHeaderRow: View { } } -private struct ContextSidebarNativeList: NSViewRepresentable { - typealias NSViewType = ContextSidebarListContainer - typealias List = ListView - typealias Renderer = @MainActor (ContextSidebarListItem) -> AnyView +private struct SidebarNativeList: NSViewRepresentable { + typealias NSViewType = SidebarListContainer + typealias List = ListView + typealias Renderer = @MainActor (SidebarListItem) -> AnyView - let items: [ContextSidebarListItem] + let items: [SidebarListItem] /// Whether adding and removing rows animates. /// /// History says no. Its content reorders as a side effect of something the @@ -358,10 +351,10 @@ private struct ContextSidebarNativeList: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme init( - items: [ContextSidebarListItem], + items: [SidebarListItem], animatesStructuralChanges: Bool = true, blankAreaMenu: (() -> [NativeMenuEntry])? = nil, - @ViewBuilder renderer: @escaping @MainActor (ContextSidebarListItem) -> some View + @ViewBuilder renderer: @escaping @MainActor (SidebarListItem) -> some View ) { self.items = items self.animatesStructuralChanges = animatesStructuralChanges @@ -372,7 +365,7 @@ private struct ContextSidebarNativeList: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSViewType { - ContextSidebarListContainer(listView: context.coordinator.makeListView()) + SidebarListContainer(listView: context.coordinator.makeListView()) } func updateNSView(_ container: NSViewType, context: Context) { @@ -392,7 +385,7 @@ private struct ContextSidebarNativeList: NSViewRepresentable { private var renderer: Renderer? private var locale = Locale.current private var colorScheme = ColorScheme.light - private var content: [ContextSidebarListItem] = [] + private var content: [SidebarListItem] = [] func makeListView() -> List { let listView = List() @@ -401,7 +394,7 @@ private struct ContextSidebarNativeList: NSViewRepresentable { listView.bottomInset = Metrics.Space.xs listView.estimatedRowHeight = Metrics.Sidebar.navigationRowHeight listView.rows { - ListRow(ContextSidebarHostingRow.self) + ListRow(SidebarHostingRow.self) .height { item, _ in item.height } .configure { [weak self] row, item, _ in self?.configure(row, item: item) @@ -412,7 +405,7 @@ private struct ContextSidebarNativeList: NSViewRepresentable { func update( listView: List, - items: [ContextSidebarListItem], + items: [SidebarListItem], renderer: @escaping Renderer, locale: Locale, colorScheme: ColorScheme, @@ -432,7 +425,7 @@ private struct ContextSidebarNativeList: NSViewRepresentable { content = items } - private func configure(_ row: ContextSidebarHostingRow, item: ContextSidebarListItem) { + private func configure(_ row: SidebarHostingRow, item: SidebarListItem) { guard let renderer else { return } let root = renderer(item) .id(item.id) @@ -450,11 +443,11 @@ private struct ContextSidebarNativeList: NSViewRepresentable { /// this far. That is exactly the distinction the blank-area menu needs, and it /// needs no geometry to make it. @MainActor -final class ContextSidebarListContainer: NSView { - let listView: ListView +final class SidebarListContainer: NSView { + let listView: ListView var blankAreaMenu: (() -> [NativeMenuEntry])? - init(listView: ListView) { + init(listView: ListView) { self.listView = listView super.init(frame: .zero) listView.translatesAutoresizingMaskIntoConstraints = false @@ -479,7 +472,7 @@ final class ContextSidebarListContainer: NSView { } @MainActor -private final class ContextSidebarHostingRow: ListRowView { +private final class SidebarHostingRow: ListRowView { private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) override init(frame: CGRect) { @@ -508,7 +501,7 @@ private final class ContextSidebarHostingRow: ListRowView { /// The global Snippet shelf. Execution always respects the Snippet's saved /// Targets; the focused terminal is never an implicit destination. -private struct SSHSessionSnippetsSidebar: View { +private struct SnippetsSidebarView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -524,7 +517,7 @@ private struct SSHSessionSnippetsSidebar: View { ContentUnavailableView.search(text: query) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ContextSidebarNativeList( + SidebarNativeList( items: snippetListItems, // A fold removes rows during the header's click. Matching // Assistant, keep that structural update synchronous. @@ -534,7 +527,7 @@ private struct SSHSessionSnippetsSidebar: View { switch item.content { case .section(let section, let title, let count): if section.snippetExpansionID == nil { - contextSidebarSectionTitle(title) + sidebarSectionTitle(title) } else { SnippetSidebarSectionHeaderRow( section: section, title: title, count: count @@ -567,7 +560,7 @@ private struct SSHSessionSnippetsSidebar: View { } } - private var snippetListItems: [ContextSidebarListItem] { + private var snippetListItems: [SidebarListItem] { if !query.isEmpty { return SnippetSidebarListBuilder.searchItems(displayedSnippets) } @@ -720,7 +713,7 @@ private struct SidebarCard: View { } } -private struct AdaptiveHistorySidebar: View { +private struct HistorySidebarView: View { @Environment(WindowModel.self) private var window var body: some View { @@ -772,14 +765,14 @@ private struct SSHCommandHistorySidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .sidebarBlankAreaMenu { globalMenu } } else { - ContextSidebarNativeList( + SidebarNativeList( items: historyListItems, animatesStructuralChanges: false, blankAreaMenu: { globalMenu } ) { item in switch item.content { case .section(_, let title, _): - contextSidebarSectionTitle(title) + sidebarSectionTitle(title) case .history(let entry, _): historyRow(entry) case .snippet, .sftpHistory, .connection: @@ -921,9 +914,9 @@ private struct SSHCommandHistorySidebar: View { } } - private var historyListItems: [ContextSidebarListItem] { + private var historyListItems: [SidebarListItem] { let entries = allEntries.map { - ContextSidebarListItem.history($0, isNaming: namingEntryID == $0.id) + SidebarListItem.history($0, isNaming: namingEntryID == $0.id) } guard !query.isEmpty else { return entries } return [.section(.results, title: String(localized: "Results"))] + entries @@ -942,8 +935,8 @@ private struct SSHCommandHistorySidebar: View { } private func insert(_ entry: CommandHistoryEntry) { - guard let tab = window.selectedWorkspace, let sshSession = tab.focusedPane.sshSession else { return } - tab.deliver(entry.command, from: sshSession) + guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return } + workspace.deliver(entry.command, from: sshSession) } private func execute(_ entry: CommandHistoryEntry) { @@ -955,8 +948,8 @@ private struct SSHCommandHistorySidebar: View { } private func executeNow(_ entry: CommandHistoryEntry) { - guard let tab = window.selectedWorkspace, let sshSession = tab.focusedPane.sshSession else { return } - tab.deliver(entry.command, from: sshSession, submit: true) + guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return } + workspace.deliver(entry.command, from: sshSession, submit: true) destructiveEntry = nil } @@ -1029,8 +1022,8 @@ private struct SFTPBrowseHistorySidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .sidebarBlankAreaMenu { globalMenu } } else { - ContextSidebarNativeList( - items: entries.map(ContextSidebarListItem.sftpHistory), + SidebarNativeList( + items: entries.map(SidebarListItem.sftpHistory), animatesStructuralChanges: false, blankAreaMenu: { globalMenu } ) { item in @@ -1149,7 +1142,7 @@ private struct ConnectionHistorySidebar: View { // Never animated. Opening a host moves it to the top of // Recents, and animating that reorder made the list jump // upwards under a pointer aimed at the row that had just moved. - ContextSidebarNativeList( + SidebarNativeList( items: hosts.map { .connection($0, address: app.hostLibrary.loginAddress(for: $0)) }, diff --git a/Termind/Features/Snippets/SnippetsPageView.swift b/Termind/Features/Snippets/SnippetsPageView.swift index 2e4d5226..d8ee6ce0 100644 --- a/Termind/Features/Snippets/SnippetsPageView.swift +++ b/Termind/Features/Snippets/SnippetsPageView.swift @@ -69,7 +69,7 @@ struct SnippetsPageView: ManagedCardPage { private var selection: Set { Set( - window.detailSelection(for: .snippets).compactMap { detail in + window.vaultSelection(for: .snippets).compactMap { detail in detail.snippetID }) } @@ -285,18 +285,18 @@ struct SnippetsPageView: ManagedCardPage { let valid = ids.intersection(Set(app.snippets.snippets.map(\.id))) state.selectedPackageID = nil let ordered = visibleSnippets.filter { valid.contains($0.id) } - .map { ContextPanelDetailID.snippet($0.id) } - window.setDetailSelection(ordered, for: .snippets) + .map { VaultSelectionID.snippet($0.id) } + window.setVaultSelection(ordered, for: .snippets) } private func selectPackage(_ id: SnippetPackage.ID) { - window.clearDetailSelection(for: .snippets) + window.clearVaultSelection(for: .snippets) state.selectedPackageID = id } private func saveSnippet(_ snippet: Snippet) { app.snippets.upsert(snippet) - window.selectDetail(.snippet(snippet.id), on: .snippets) + window.selectVaultItem(.snippet(snippet.id), on: .snippets) } private func duplicate(_ snippet: Snippet) { @@ -322,13 +322,13 @@ struct SnippetsPageView: ManagedCardPage { { state.selectedPackageID = nil } - window.pruneDetailSelections(in: app.snippets) + window.pruneVaultSelections(in: app.snippets) } private func deleteSnippets(_ snippets: [Snippet]) { app.snippets.delete(snippetIDs: snippets.map(\.id)) state.confirmation = nil - window.pruneDetailSelections(in: app.snippets) + window.pruneVaultSelections(in: app.snippets) } private func requestExecution(_ snippets: [Snippet]) { diff --git a/Termind/Features/Tabs/WindowState.swift b/Termind/Features/Tabs/WindowState.swift deleted file mode 100644 index e8f04e45..00000000 --- a/Termind/Features/Tabs/WindowState.swift +++ /dev/null @@ -1,214 +0,0 @@ -import SwiftUI - -/// A stable, feature-owned route into the ContextPanel. -/// -/// The ContextPanel treats `kind` as an extension point. `WindowModel` only needs -/// the page for selection scoping and never switches over business model types. -nonisolated struct ContextPanelDetailID: Hashable, Sendable, Identifiable { - nonisolated enum Kind { - static let host = "host" - static let key = "key" - static let identity = "identity" - static let snippet = "snippet" - } - - let kind: String - let rawID: UUID - let page: VaultPage - - nonisolated var id: Self { self } - nonisolated func belongs(to page: VaultPage) -> Bool { self.page == page } - - nonisolated static func host(_ id: Host.ID) -> Self { - Self(kind: Kind.host, rawID: id, page: .hosts) - } - - nonisolated static func key(_ id: SSHKeyRef.ID) -> Self { - Self(kind: Kind.key, rawID: id, page: .keychain) - } - - nonisolated static func identity(_ id: Identity.ID) -> Self { - Self(kind: Kind.identity, rawID: id, page: .keychain) - } - - nonisolated static func snippet(_ id: Snippet.ID) -> Self { - Self(kind: Kind.snippet, rawID: id, page: .snippets) - } - - var hostID: Host.ID? { kind == Kind.host ? rawID : nil } - var keyID: SSHKeyRef.ID? { kind == Kind.key ? rawID : nil } - var identityID: Identity.ID? { kind == Kind.identity ? rawID : nil } - var snippetID: Snippet.ID? { kind == Kind.snippet ? rawID : nil } -} - -/// The one left sidebar's window-lifetime state. -/// -/// Navigation and contextual tools share a physical column. The selected mode -/// belongs to the window and remains unchanged as its destination changes. -@Observable -final class WindowSidebarState { - nonisolated enum Mode: String, Hashable, Sendable, CaseIterable, Identifiable { - case navigation, assistant, snippets, history - - nonisolated var id: String { rawValue } - - var label: LocalizedStringKey { - switch self { - case .navigation: "Navigator" - case .assistant: "Assistant" - case .snippets: "Snippets" - case .history: "History" - } - } - - var symbol: String { - switch self { - case .navigation: "list.bullet" - case .assistant: "sparkles" - case .snippets: "curlybraces" - case .history: "clock.arrow.circlepath" - } - } - } - - var isPresented = true - - /// Global Assistant panel navigation is independent from the selected - /// top-level destination. `nil` is the history + composer landing screen. - var assistantConversationID: ConversationModel.ID? - var assistantDraft = ConversationModel() - var isAssistantArchivePresented = false - - /// What the focused pane is, for whichever composer this column is showing. - /// - /// Here rather than on a conversation, because it describes the window's - /// current view of a host — not the chat. `WindowModel.refreshSessionContext` - /// is the only writer, and it clears this to `.none` when no Workspace is - /// selected so a stale host and directory cannot be left on screen. - var sessionContext: SessionContext = .none - - var mode: Mode = .navigation - - private var selections: [VaultPage: [ContextPanelDetailID]] = [:] - private var detailOverride: ContextPanelDetailID? - private var detailOverridePaneID: Pane.ID? - - var availableModes: [Mode] { - Mode.allCases - } - - func isModeEnabled(_ mode: Mode) -> Bool { - availableModes.contains(mode) - } - - func reconcileMode() { - // Every mode has a useful global or focus-sensitive presentation. - } - - func selection(for page: VaultPage) -> [ContextPanelDetailID] { - selections[page] ?? [] - } - - func setSelection(_ details: [ContextPanelDetailID], for page: VaultPage) { - var seen: Set = [] - selections[page] = details.filter { - $0.belongs(to: page) && seen.insert($0).inserted - } - } - - func select(_ detail: ContextPanelDetailID, on page: VaultPage) { - setSelection([detail], for: page) - } - - func toggle(_ detail: ContextPanelDetailID, on page: VaultPage) { - var details = selection(for: page) - if let index = details.firstIndex(of: detail) { - details.remove(at: index) - } else { - details.append(detail) - } - setSelection(details, for: page) - } - - func clearSelection(for page: VaultPage) { - selections[page] = [] - } - - func activeDetails(for pane: Pane?) -> [ContextPanelDetailID] { - guard let pane else { return [] } - if pane.id == detailOverridePaneID, let detailOverride { - return [detailOverride] - } - return pane.hostID.map { [.host($0)] } ?? [] - } - - func show(_ detail: ContextPanelDetailID, scopedTo pane: Pane) { - detailOverride = detail - detailOverridePaneID = pane.id - } - - func focusedPaneDidChange(to pane: Pane?) { - guard pane?.id != detailOverridePaneID else { return } - detailOverride = nil - detailOverridePaneID = nil - } - - func clearOverride() { - detailOverride = nil - detailOverridePaneID = nil - } - - func pruneSelections(available: Set) { - for page in VaultPage.allCases { - let valid = selection(for: page).filter(available.contains) - if valid != selection(for: page) { selections[page] = valid } - } - if let detailOverride, !available.contains(detailOverride) { clearOverride() } - } -} - -@Observable -final class WindowPresentationState { - enum SheetRoute: Identifiable { - case hostChooser(HostChooserPurpose) - case quickOpen - - var id: String { - switch self { - case .hostChooser(let purpose): "host-chooser-\(purpose.id)" - case .quickOpen: "quick-open" - } - } - } - - var sheet: SheetRoute? - - var hostChooser: HostChooserPurpose? { - get { - guard case .hostChooser(let purpose) = sheet else { return nil } - return purpose - } - set { - sheet = newValue.map(SheetRoute.hostChooser) - } - } - - var isQuickOpenPresented: Bool { - get { - guard case .quickOpen = sheet else { return false } - return true - } - set { - if newValue { - sheet = .quickOpen - } else if case .quickOpen = sheet { - sheet = nil - } - } - } -} - -@Observable -final class WorkspaceDragState { - var payload: DragPayload? -} diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index a9ce2074..feff2d17 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -189,7 +189,8 @@ final class SSHSessionModel: Identifiable { /// presents the editor; the sshSession remains disconnected until it receives /// either Continue or Continue and Save. var pendingAuthentication: SSHAuthenticationPrompt? - let connectionJourney = SSHConnectionJourney() + let connection = RemoteSessionConnectionController() + var connectionJourney: SSHConnectionJourney { connection.journey } let terminalState: TerminalViewState #if canImport(AppKit) @@ -199,13 +200,13 @@ final class SSHSessionModel: Identifiable { let terminalViewOwner: PersistentTerminalViewOwner #endif private let terminalBackend: InMemoryTerminalSession - private weak var statusStore: HostLibraryModel? + private weak var statusStore: (any RemoteSessionHostStore)? weak var commandHistoryStore: CommandHistoryModel? private var ssh: SSHSessionActor? var connectionCoordinator: RemoteConnectionCoordinator? private var connectionTask: Task? private var connectionAttemptID: UUID? - private let reconnectController = SessionReconnectController() + private var reconnectController: SessionReconnectController { connection.reconnect } private var hasConnectedOnce = false private var isRecoveringConnection = false private var workingDirectoryObservation: AnyCancellable? @@ -235,7 +236,7 @@ final class SSHSessionModel: Identifiable { private var pendingInitialCommand: PendingInitialCommand? private var commandExcludedFromHistory: String? - var reconnectState: ReconnectState? { reconnectController.state } + var reconnectState: ReconnectState? { connection.reconnectState } convenience init(hostID: Host.ID, title: String, initialCommand: String? = nil) { self.init( @@ -502,11 +503,11 @@ final class SSHSessionModel: Identifiable { private static let bracketedPasteStart = Data("\u{1B}[200~".utf8) private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8) - func connect(using store: HostLibraryModel) { + func connect(using store: any RemoteSessionHostStore) { startConnection(using: store) } - private func startConnection(using store: HostLibraryModel) { + private func startConnection(using store: any RemoteSessionHostStore) { resetCommandGeneratorShellState() let reconnecting = hasConnectedOnce && isRecoveringConnection workingDirectory = nil @@ -776,7 +777,7 @@ final class SSHSessionModel: Identifiable { func continueAuthentication( identityID: Identity.ID?, username: String, password: String?, passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool, - using store: HostLibraryModel + using store: any RemoteSessionHostStore ) { let selectedIdentity = identityID.flatMap { store.identity($0) } let trimmedUsername = (selectedIdentity?.username ?? username) @@ -884,7 +885,7 @@ final class SSHSessionModel: Identifiable { connect(using: store) } - func trustAndConnect(_ observation: SSHHostKeyObservation, using store: HostLibraryModel) { + func trustAndConnect(_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore) { store.trust( KnownHostEntry( hostname: observation.hostname, port: observation.port, @@ -901,7 +902,7 @@ final class SSHSessionModel: Identifiable { } func continueWithTemporaryHostKey( - _ observation: SSHHostKeyObservation, using store: HostLibraryModel + _ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore ) { guard let hostID = pendingHostKeyHostID else { return } temporaryHostKeys[hostID] = observation.identity @@ -1069,7 +1070,7 @@ final class SSHSessionModel: Identifiable { } private func handleConnectionAttemptFailure( - _ error: any Error, store: HostLibraryModel, reconnecting: Bool + _ error: any Error, store: any RemoteSessionHostStore, reconnecting: Bool ) { connectionAttemptID = nil if hasConnectedOnce || reconnecting { @@ -1093,7 +1094,7 @@ final class SSHSessionModel: Identifiable { scheduleReconnect(after: message, using: store) } - private func scheduleReconnect(after message: String, using store: HostLibraryModel) { + private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) { resetCommandGeneratorShellState() isRecoveringConnection = true connectionAttemptID = nil @@ -1110,7 +1111,7 @@ final class SSHSessionModel: Identifiable { } } - private func presentReconnectJourney(using store: HostLibraryModel) { + private func presentReconnectJourney(using store: any RemoteSessionHostStore) { guard let host = store.host(hostID) ?? transientHost else { return } connectionJourney.begin( host: host, route: store.route(for: host), purpose: .sshSession, @@ -1172,7 +1173,7 @@ final class SSHSessionModel: Identifiable { } private func discoverHostKey( - for host: Host, using store: HostLibraryModel, attemptID: UUID + for host: Host, using store: any RemoteSessionHostStore, attemptID: UUID ) async { do { let observation = try await SSHSessionActor.discoverHostKey( diff --git a/Termind/Features/Terminal/TerminalPlaceholderView.swift b/Termind/Features/Terminal/TerminalPlaceholderView.swift index 260db28f..bb638fa3 100644 --- a/Termind/Features/Terminal/TerminalPlaceholderView.swift +++ b/Termind/Features/Terminal/TerminalPlaceholderView.swift @@ -6,10 +6,10 @@ struct TerminalPlaceholderView: View { let sshSession: SSHSessionModel /// The Workspace holding this pane owns broadcast input routing. let workspace: Workspace - /// Hidden tabs remain mounted to preserve Ghostty's surface, but stop + /// Hidden workspaces remain mounted to preserve Ghostty's surface, but stop /// rendering frames until they come forward again. let isVisible: Bool - /// Only the focused terminal pane in the visible tab should own keyboard + /// Only the focused terminal pane in the visible workspace should own keyboard /// focus. A split can keep several surfaces visible at once. let requestsFocus: Bool let externalFileDrop: LocalFileDropRequest? @@ -46,7 +46,7 @@ struct TerminalPlaceholderView: View { .overlay(alignment: .bottom) { uploadDecoration } .overlay { connectionJourney } .animation( - Motion.tabSwitch, + Motion.workspaceSwitch, value: isExternalFileDropTargeted ) .animation(Motion.panel, value: uploadNotice) diff --git a/Termind/Features/Vault/VaultPageStates.swift b/Termind/Features/Vault/VaultPageStates.swift index baff6a9c..0d60f15f 100644 --- a/Termind/Features/Vault/VaultPageStates.swift +++ b/Termind/Features/Vault/VaultPageStates.swift @@ -1,5 +1,78 @@ import Foundation +/// Stable identity for the ordered selections owned by the three Vault manager pages. +nonisolated struct VaultSelectionID: Hashable, Sendable, Identifiable { + nonisolated enum Kind { + static let host = "host" + static let key = "key" + static let identity = "identity" + static let snippet = "snippet" + } + + let kind: String + let rawID: UUID + let page: VaultPage + + nonisolated var id: Self { self } + nonisolated func belongs(to page: VaultPage) -> Bool { self.page == page } + + nonisolated static func host(_ id: Host.ID) -> Self { + Self(kind: Kind.host, rawID: id, page: .hosts) + } + + nonisolated static func key(_ id: SSHKeyRef.ID) -> Self { + Self(kind: Kind.key, rawID: id, page: .keychain) + } + + nonisolated static func identity(_ id: Identity.ID) -> Self { + Self(kind: Kind.identity, rawID: id, page: .keychain) + } + + nonisolated static func snippet(_ id: Snippet.ID) -> Self { + Self(kind: Kind.snippet, rawID: id, page: .snippets) + } + + var hostID: Host.ID? { kind == Kind.host ? rawID : nil } + var keyID: SSHKeyRef.ID? { kind == Kind.key ? rawID : nil } + var identityID: Identity.ID? { kind == Kind.identity ? rawID : nil } + var snippetID: Snippet.ID? { kind == Kind.snippet ? rawID : nil } +} + +@Observable +final class VaultSelectionState { + private var selections: [VaultPage: [VaultSelectionID]] = [:] + + func selection(for page: VaultPage) -> [VaultSelectionID] { selections[page] ?? [] } + + func set(_ items: [VaultSelectionID], for page: VaultPage) { + var seen: Set = [] + selections[page] = items.filter { + $0.belongs(to: page) && seen.insert($0).inserted + } + } + + func select(_ item: VaultSelectionID, on page: VaultPage) { set([item], for: page) } + + func toggle(_ item: VaultSelectionID, on page: VaultPage) { + var items = selection(for: page) + if let index = items.firstIndex(of: item) { + items.remove(at: index) + } else { + items.append(item) + } + set(items, for: page) + } + + func clear(_ page: VaultPage) { selections[page] = [] } + + func prune(available: Set) { + for page in VaultPage.allCases { + let valid = selection(for: page).filter(available.contains) + if valid != selection(for: page) { selections[page] = valid } + } + } +} + /// The three manager pages' query and confirmation state, held for the life of /// the window rather than of the view. /// @@ -13,6 +86,7 @@ final class VaultPageStates { let hosts = HostsPageState() let keychain = KeychainPageState() let snippets = SnippetsPageState() + let selection = VaultSelectionState() /// The query belonging to a page, or nil for Welcome, which searches hosts /// with its own in-page field. diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 0b32ca9a..d064861e 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The first pinned tab's main content. Its navigator is mounted by the -/// window-level split view and remains alive while top-level tabs change. +/// The first pinned destination's main content. Its navigator is mounted by the +/// window-level split view and remains alive while top-level destinations change. struct VaultView: View { @Environment(WindowModel.self) private var window let pages: VaultPageStates @@ -50,7 +50,7 @@ struct VaultWorkDetailView: View { identity: id.flatMap(app.hostLibrary.identity), create: { identity, password in try app.hostLibrary.upsert(identity, passwordUpdate: password) - window.selectDetail(.identity(identity.id), on: .keychain) + window.selectVaultItem(.identity(identity.id), on: .keychain) }, close: window.closeVaultWork ) @@ -67,7 +67,7 @@ struct VaultWorkDetailView: View { snippet: snippetID.flatMap(app.hostLibrary.snippet), packageID: packageID, save: { snippet in app.hostLibrary.upsert(snippet) - window.selectDetail(.snippet(snippet.id), on: .snippets) + window.selectVaultItem(.snippet(snippet.id), on: .snippets) }, close: window.closeVaultWork ) diff --git a/Termind/Features/Tabs/EmptyPaneView.swift b/Termind/Features/Workspaces/EmptyPaneView.swift similarity index 97% rename from Termind/Features/Tabs/EmptyPaneView.swift rename to Termind/Features/Workspaces/EmptyPaneView.swift index 7f746450..cf818461 100644 --- a/Termind/Features/Tabs/EmptyPaneView.swift +++ b/Termind/Features/Workspaces/EmptyPaneView.swift @@ -9,7 +9,7 @@ import SwiftUI /// server. /// /// What fills it has to be something already open, so the slot advertises the -/// one gesture that works: drag a tab in. +/// one gesture that works: drag a workspace in. /// /// Purely the prompt. The drop belongs to `PaneView`, which handles every pane /// now rather than only the empty ones — two nested drop targets over the same diff --git a/Termind/Features/Tabs/Pane.swift b/Termind/Features/Workspaces/Pane.swift similarity index 97% rename from Termind/Features/Tabs/Pane.swift rename to Termind/Features/Workspaces/Pane.swift index 25683a64..e0362660 100644 --- a/Termind/Features/Tabs/Pane.swift +++ b/Termind/Features/Workspaces/Pane.swift @@ -2,9 +2,9 @@ import Foundation /// What a single pane is showing. /// -/// The plan called this `TabContent`, but splitting means a tab can hold several -/// of these at once, so the unit of content is the pane and a tab owns a tree of -/// them. A tab's title, icon, and status are derived from its *focused* pane. +/// The plan called this `WorkspaceContent`, but splitting means a workspace can hold several +/// of these at once, so the unit of content is the pane and a workspace owns a tree of +/// them. A workspace's title, icon, and status are derived from its *focused* pane. /// enum PaneContent { // Work, created on demand and listed in the sidebar. @@ -18,7 +18,7 @@ enum PaneContent { /// Split makes one of these rather than opening a connection, because /// choosing a layout and choosing a machine are two decisions and only one /// of them was asked for. What fills it has to be something already open: - /// drag a tab out of the strip and drop it here, or move a pane over from + /// drag a workspace out of the strip and drop it here, or move a pane over from /// elsewhere in this same Workspace. case empty } @@ -65,7 +65,7 @@ nonisolated enum PaneKind: Hashable, Sendable { /// answer for yourself and nobody else can. /// /// A manager page is a destination rather than a thing you own — there is - /// one Keychain and it is called Keychain — and renaming it would put a tab + /// one Keychain and it is called Keychain — and renaming it would put a workspace /// in the bar that no longer matches the sidebar row that opens it. A form /// and an unfilled slot are the same story: both are named after the state /// they are in, and both stop existing when that state ends. @@ -81,7 +81,7 @@ nonisolated enum PaneKind: Hashable, Sendable { } } -/// One rectangle of content inside a tab. +/// One rectangle of content inside a workspace. @Observable final class Pane: Identifiable { let id = UUID() @@ -252,7 +252,7 @@ final class Pane: Identifiable { } } - /// Resolved against the store so a renamed host renames its tabs — unless + /// Resolved against the store so a renamed host renames its workspaces — unless /// the user named this pane, in which case their word wins everywhere. func title(in store: HostLibraryModel) -> String { if let customTitle { return customTitle } @@ -312,7 +312,7 @@ extension Pane { /// One shell on a host, already marked as connecting. /// /// Here rather than inside `Workspace.sshSession(host:)` because a session pane - /// is now born in two places — a tab of its own, and one cell of a batch + /// is now born in two places — a workspace of its own, and one cell of a batch /// Workspace — and a second copy of this is a second chance to forget the /// status flip. static func sshSession( @@ -362,7 +362,7 @@ nonisolated enum SplitSide: Hashable, Sendable { case after } -/// A tab's layout: a binary tree of panes. +/// A workspace's layout: a binary tree of panes. /// /// The tree is recursive so nested splits need no model change later. indirect enum SplitNode { @@ -375,7 +375,7 @@ indirect enum SplitNode { /// The fraction is stored here rather than left to `HSplitView`, which owned /// its divider position and handed leftover space to whichever child asked /// last. Two consequences of that we needed gone: a drop previewed as "the - /// right half" landed at whatever width AppKit chose, and resizing the tab + /// right half" landed at whatever width AppKit chose, and resizing the workspace /// grew one pane while the other stayed where it was. Reading the layout /// from a stored ratio makes both exact — the preview is a promise the /// layout keeps, and every size is recomputed proportionally on resize. @@ -549,7 +549,7 @@ indirect enum SplitNode { } /// Removes a pane, collapsing the split that held it. Returns `nil` when the - /// tree becomes empty, which is the caller's signal to close the tab. + /// tree becomes empty, which is the caller's signal to close the workspace. func removing(_ paneID: Pane.ID) -> SplitNode? { switch self { case .leaf(let pane): diff --git a/Termind/Features/Tabs/PersistentWorkspaceDeckLayout.swift b/Termind/Features/Workspaces/PersistentWorkspaceDeckLayout.swift similarity index 100% rename from Termind/Features/Tabs/PersistentWorkspaceDeckLayout.swift rename to Termind/Features/Workspaces/PersistentWorkspaceDeckLayout.swift diff --git a/Termind/Features/Tabs/WindowModel.swift b/Termind/Features/Workspaces/WindowModel.swift similarity index 93% rename from Termind/Features/Tabs/WindowModel.swift rename to Termind/Features/Workspaces/WindowModel.swift index be1d345f..293b76b2 100644 --- a/Termind/Features/Tabs/WindowModel.swift +++ b/Termind/Features/Workspaces/WindowModel.swift @@ -12,7 +12,11 @@ enum SessionOpenPlacement: Equatable { /// `FocusedValues.window`. @Observable final class WindowModel { - var workspaces: [Workspace] = [] + let workspaceDeck: WorkspaceDeckModel + var workspaces: [Workspace] { + get { workspaceDeck.workspaces } + set { workspaceDeck.workspaces = newValue } + } /// The top-level shell has two pinned entries followed by dynamic SSH/SFTP /// Workspaces. This is the single source of truth for workspace selection. var selectedDestination: WindowDestination = .vault @@ -53,13 +57,7 @@ final class WindowModel { for workspace in workspaces { workspace.configureSFTPHistory(using: sftpHistoryStore) } } } - var assistantConversationDeletionHandler: ((ConversationModel.ID) -> Void)? - var assistantPinHandler: ((ConversationModel.ID) -> Void)? - var assistantMoveHandler: ((ConversationModel.ID, Bool, ConversationModel.ID?) -> Void)? - var assistantRenameHandler: ((ConversationModel.ID, String) -> Void)? - var assistantArchiveHandler: ((ConversationModel.ID, Bool) -> Void)? - var assistantForkHandler: ((ConversationModel.ID, UUID, Bool) -> Void)? - var assistantEditHandler: ((ConversationModel.ID, UUID, String, SessionContext, Bool) -> Void)? + var assistantCommands: AssistantConversationCommands? var assistantOperationError: String? var hostStore: HostLibraryModel? { didSet { refreshSessionContext() } @@ -80,66 +78,49 @@ final class WindowModel { set { dragState.payload = newValue } } - /// Ordered selection saved for a manager page. - func detailSelection(for page: VaultPage) -> [ContextPanelDetailID] { - sidebar.selection(for: page) + /// Ordered selection saved for a Vault manager page. + func vaultSelection(for page: VaultPage) -> [VaultSelectionID] { + vaultPages.selection.selection(for: page) } /// Replaces a page selection, removing duplicates and ids belonging to a /// different page while keeping the first occurrence of each item. - func setDetailSelection(_ details: [ContextPanelDetailID], for page: VaultPage) { - sidebar.setSelection(details, for: page) + func setVaultSelection(_ details: [VaultSelectionID], for page: VaultPage) { + vaultPages.selection.set(details, for: page) } - func selectDetail(_ detail: ContextPanelDetailID, on page: VaultPage) { - sidebar.select(detail, on: page) + func selectVaultItem(_ detail: VaultSelectionID, on page: VaultPage) { + vaultPages.selection.select(detail, on: page) } /// Shift-click semantics: selected items retain their order; a newly added /// item is appended at the point the user selected it. - func toggleDetail(_ detail: ContextPanelDetailID, on page: VaultPage) { - sidebar.toggle(detail, on: page) + func toggleVaultItem(_ detail: VaultSelectionID, on page: VaultPage) { + vaultPages.selection.toggle(detail, on: page) } - func clearDetailSelection(for page: VaultPage) { - sidebar.clearSelection(for: page) - } - - /// The selection that belongs to the content currently in front. Manager - /// pages use their remembered selection; work attached to a host describes - /// that host without overwriting the Hosts page selection. - var activeDetailSelection: [ContextPanelDetailID] { - if selectedDestination == .vault, let page = vaultDestination.page { - return sidebar.selection(for: page) - } - return sidebar.activeDetails(for: selectedWorkspace?.focusedPane) - } - - /// Resolves the active ids against live store data without mutating the - /// remembered selection. This makes deletion safe even before the next - /// pruning pass runs. - func activeDetailSelection(in store: HostLibraryModel) -> [ContextPanelDetailID] { - activeDetailSelection.filter(Self.availableContextPanelDetails(in: store).contains) + func clearVaultSelection(for page: VaultPage) { + vaultPages.selection.clear(page) } /// Removes records deleted out from under a remembered selection. - func pruneDetailSelections(in store: HostLibraryModel) { - sidebar.pruneSelections(available: Self.availableContextPanelDetails(in: store)) + func pruneVaultSelections(in store: HostLibraryModel) { + vaultPages.selection.prune(available: Self.availableVaultSelections(in: store)) } - func pruneDetailSelections(in store: CredentialLibraryModel) { - let available = Set(store.keys.map { ContextPanelDetailID.key($0.id) }) + func pruneVaultSelections(in store: CredentialLibraryModel) { + let available = Set(store.keys.map { VaultSelectionID.key($0.id) }) .union(store.identities.map { .identity($0.id) }) - sidebar.pruneSelections(available: available) + vaultPages.selection.prune(available: available) } - func pruneDetailSelections(in store: SnippetLibraryModel) { - sidebar.pruneSelections( - available: Set(store.snippets.map { ContextPanelDetailID.snippet($0.id) }) + func pruneVaultSelections(in store: SnippetLibraryModel) { + vaultPages.selection.prune( + available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }) ) } - private static func availableContextPanelDetails(in store: HostLibraryModel) -> Set { + private static func availableVaultSelections(in store: HostLibraryModel) -> Set { Set(store.hosts.map { .host($0.id) }) .union(store.keys.map { .key($0.id) }) .union(store.identities.map { .identity($0.id) }) @@ -196,14 +177,17 @@ final class WindowModel { /// Closed workspaces, most recent last. ⇧⌘T pops from here. The Workspace retains /// its layout, names, and conversation, while `prepareForReopen` replaces /// terminal models because a dead SSH channel cannot be revived. - private var reopenStack: [Workspace] = [] + private var reopenStack: [Workspace] { + get { workspaceDeck.reopenStack } + set { workspaceDeck.reopenStack = newValue } + } init( workspaces: [Workspace] = [], vaultDestination: VaultDestination = .welcome, assistantLibrary: AssistantLibraryModel = AssistantLibraryModel() ) { - self.workspaces = workspaces + self.workspaceDeck = WorkspaceDeckModel(workspaces: workspaces) self.vaultDestination = vaultDestination self.assistantLibrary = assistantLibrary selectedDestination = workspaces.first.map { .workspace($0.id) } ?? .vault @@ -464,8 +448,8 @@ final class WindowModel { } func deleteAssistantConversation(_ id: ConversationModel.ID) { - if let assistantConversationDeletionHandler { - assistantConversationDeletionHandler(id) + if let assistantCommands { + assistantCommands.delete(id) return } let wasArchived = assistantLibrary.conversation(id: id)?.isArchived == true @@ -479,7 +463,7 @@ final class WindowModel { } func toggleAssistantPin(_ id: ConversationModel.ID) { - if let assistantPinHandler { assistantPinHandler(id) } + if let assistantCommands { assistantCommands.togglePin(id) } else if let conversation = assistantLibrary.conversation(id: id), !conversation.isArchived, !conversation.isStreaming, !conversation.isCompacting { @@ -495,8 +479,8 @@ final class WindowModel { toPinned: Bool, before nextID: ConversationModel.ID? ) { - if let assistantMoveHandler { - assistantMoveHandler(id, toPinned, nextID) + if let assistantCommands { + assistantCommands.move(id, toPinned, nextID) } else if let updates = assistantLibrary.orderUpdates( moving: id, toPinned: toPinned, before: nextID ) { @@ -504,7 +488,7 @@ final class WindowModel { } } func renameAssistantConversation(_ id: ConversationModel.ID, to title: String) { - if let assistantRenameHandler { assistantRenameHandler(id, title) } + if let assistantCommands { assistantCommands.rename(id, title) } else { let value = title.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { @@ -513,7 +497,7 @@ final class WindowModel { } } func setAssistantArchived(_ id: ConversationModel.ID, archived: Bool) { - if let assistantArchiveHandler { assistantArchiveHandler(id, archived) } + if let assistantCommands { assistantCommands.archive(id, archived) } else if let conversation = assistantLibrary.conversation(id: id), !conversation.isStreaming, !conversation.isCompacting { @@ -524,13 +508,13 @@ final class WindowModel { func forkAssistantConversation( _ id: ConversationModel.ID, at messageID: UUID, inSidebar: Bool = false ) { - assistantForkHandler?(id, messageID, inSidebar) + assistantCommands?.fork(id, messageID, inSidebar) } func editAssistantUserMessage( _ id: ConversationModel.ID, messageID: UUID, text: String, context: SessionContext = .none, includesTerminalSuggestion: Bool = false ) { - assistantEditHandler?(id, messageID, text, context, includesTerminalSuggestion) + assistantCommands?.edit(id, messageID, text, context, includesTerminalSuggestion) } @discardableResult diff --git a/Termind/Features/Workspaces/WindowState.swift b/Termind/Features/Workspaces/WindowState.swift new file mode 100644 index 00000000..88920132 --- /dev/null +++ b/Termind/Features/Workspaces/WindowState.swift @@ -0,0 +1,122 @@ +import SwiftUI + +/// Window-scoped ownership for the dynamic workspace collection and its +/// recoverable close history. Navigation remains in `WindowModel`; workspace +/// lifetime no longer shares storage with Assistant and Vault state. +@Observable +final class WorkspaceDeckModel { + var workspaces: [Workspace] + var reopenStack: [Workspace] = [] + + init(workspaces: [Workspace] = []) { + self.workspaces = workspaces + } +} + +/// The one left sidebar's window-lifetime state. +/// +/// Navigation and contextual tools share a physical column. The selected mode +/// belongs to the window and remains unchanged as its destination changes. +@Observable +final class WindowSidebarState { + nonisolated enum Mode: String, Hashable, Sendable, CaseIterable, Identifiable { + case navigation, assistant, snippets, history + + nonisolated var id: String { rawValue } + + var label: LocalizedStringKey { + switch self { + case .navigation: "Navigator" + case .assistant: "Assistant" + case .snippets: "Snippets" + case .history: "History" + } + } + + var symbol: String { + switch self { + case .navigation: "list.bullet" + case .assistant: "sparkles" + case .snippets: "curlybraces" + case .history: "clock.arrow.circlepath" + } + } + } + + var isPresented = true + + /// Global Assistant panel navigation is independent from the selected + /// top-level destination. `nil` is the history + composer landing screen. + var assistantConversationID: ConversationModel.ID? + var assistantDraft = ConversationModel() + var isAssistantArchivePresented = false + + /// What the focused pane is, for whichever composer this column is showing. + /// + /// Here rather than on a conversation, because it describes the window's + /// current view of a host — not the chat. `WindowModel.refreshSessionContext` + /// is the only writer, and it clears this to `.none` when no Workspace is + /// selected so a stale host and directory cannot be left on screen. + var sessionContext: SessionContext = .none + + var mode: Mode = .navigation + + var availableModes: [Mode] { + Mode.allCases + } + + func isModeEnabled(_ mode: Mode) -> Bool { + availableModes.contains(mode) + } + + func reconcileMode() { + // Every mode has a useful global or focus-sensitive presentation. + } + +} + +@Observable +final class WindowPresentationState { + enum SheetRoute: Identifiable { + case hostChooser(HostChooserPurpose) + case quickOpen + + var id: String { + switch self { + case .hostChooser(let purpose): "host-chooser-\(purpose.id)" + case .quickOpen: "quick-open" + } + } + } + + var sheet: SheetRoute? + + var hostChooser: HostChooserPurpose? { + get { + guard case .hostChooser(let purpose) = sheet else { return nil } + return purpose + } + set { + sheet = newValue.map(SheetRoute.hostChooser) + } + } + + var isQuickOpenPresented: Bool { + get { + guard case .quickOpen = sheet else { return false } + return true + } + set { + if newValue { + sheet = .quickOpen + } else if case .quickOpen = sheet { + sheet = nil + } + } + } +} + +@Observable +final class WorkspaceDragState { + var payload: DragPayload? +} diff --git a/Termind/Features/Tabs/Workspace.swift b/Termind/Features/Workspaces/Workspace.swift similarity index 100% rename from Termind/Features/Tabs/Workspace.swift rename to Termind/Features/Workspaces/Workspace.swift diff --git a/Termind/Features/Tabs/WorkspaceContentView.swift b/Termind/Features/Workspaces/WorkspaceContentView.swift similarity index 99% rename from Termind/Features/Tabs/WorkspaceContentView.swift rename to Termind/Features/Workspaces/WorkspaceContentView.swift index 6f1735ca..b91e1d68 100644 --- a/Termind/Features/Tabs/WorkspaceContentView.swift +++ b/Termind/Features/Workspaces/WorkspaceContentView.swift @@ -688,7 +688,7 @@ private struct PaneView: View { let isOn = workspace.broadcastsInput if isHovering { Button { - withAnimation(Motion.tabSwitch) { workspace.broadcastsInput.toggle() } + withAnimation(Motion.workspaceSwitch) { workspace.broadcastsInput.toggle() } } label: { Image(systemName: "dot.radiowaves.left.and.right") .font(.system(size: 9, weight: .medium)) @@ -1059,7 +1059,7 @@ enum PaneDropZone: Equatable { /// Tracks which edge a drag is nearest while it is over a pane. /// -/// A `DropDelegate` for the same reason `TabDropDelegate` is one: the preview has +/// A `DropDelegate` for the same reason `WorkspaceDropDelegate` is one: the preview has /// to follow the pointer across four zones, and `dropDestination`'s `isTargeted` /// is a single Bool that can only say whether a drop is possible at all. private struct PaneDropDelegate: DropDelegate { diff --git a/Termind/Features/Tabs/WorkspaceNavigatorView.swift b/Termind/Features/Workspaces/WorkspaceNavigatorView.swift similarity index 100% rename from Termind/Features/Tabs/WorkspaceNavigatorView.swift rename to Termind/Features/Workspaces/WorkspaceNavigatorView.swift diff --git a/TermindTests/ArchitectureBoundaryTests.swift b/TermindTests/ArchitectureBoundaryTests.swift index 4c23b3d5..0bede5b3 100644 --- a/TermindTests/ArchitectureBoundaryTests.swift +++ b/TermindTests/ArchitectureBoundaryTests.swift @@ -16,6 +16,43 @@ struct ArchitectureBoundaryTests { #expect(!FileManager.default.fileExists(atPath: core.path)) } + @Test + func retiredFeatureDirectoriesAreGone() { + for directory in ["Tabs", "ContextPanel"] { + let url = sourceRoot.appending(path: "Features/\(directory)", directoryHint: .isDirectory) + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + } + + @Test + func sessionFeaturesDependOnTheConnectionPort() throws { + for path in ["Features/Terminal/SSHSessionModel.swift", "Features/SFTP/SFTPSessionModel.swift"] { + let text = try String( + contentsOf: sourceRoot.appending(path: path), encoding: .utf8 + ) + #expect(!text.contains("HostLibraryModel"), "\(path) bypasses RemoteSessionHostStore") + } + } + + @Test + func retiredContextPanelTypesAreGone() throws { + for file in try swiftFiles(in: "Features") { + let text = try String(contentsOf: file, encoding: .utf8) + #expect(!text.contains("ContextPanel"), "\(file.path) retains retired ContextPanel naming") + } + } + + @Test + func featureFacadesDoNotExposeTheirBackingLibrary() throws { + for file in try swiftFiles(in: "Application/FeatureModels") { + let text = try String(contentsOf: file, encoding: .utf8) + #expect( + !text.contains("\n let library: HostLibraryModel"), + "\(file.path) exposes the backing aggregate" + ) + } + } + @Test func domainImportsOnlyPlatformIndependentFrameworks() throws { try assertNoImports( diff --git a/TermindTests/ContextPanelSelectionTests.swift b/TermindTests/ContextPanelSelectionTests.swift deleted file mode 100644 index 3620f424..00000000 --- a/TermindTests/ContextPanelSelectionTests.swift +++ /dev/null @@ -1,223 +0,0 @@ -import Foundation -import Testing - -@testable import Termind - -@Suite -@MainActor -struct ContextPanelSelectionTests { - @Test - func statusBarIsReservedOnlyForWorkPanes() { - #expect(PaneKind.sshSession.showsStatusBar) - #expect(PaneKind.sftpSession.showsStatusBar) - #expect(!PaneKind.empty.showsStatusBar) - } - - @Test - func selectionIsOrderedDeduplicatedAndScopedToItsPage() { - let window = WindowModel() - let first = UUID() - let second = UUID() - window.showsSidebar = false - - window.setDetailSelection( - [.host(first), .host(second), .host(first), .key(UUID())], - for: .hosts - ) - - #expect(window.detailSelection(for: .hosts) == [.host(first), .host(second)]) - #expect(window.sidebarMode == .navigation) - #expect(!window.showsSidebar) - } - - @Test - func toggleSupportsMixedKeyAndIdentitySelection() { - let window = WindowModel() - let key = ContextPanelDetailID.key(UUID()) - let identity = ContextPanelDetailID.identity(UUID()) - - window.selectDetail(key, on: .keychain) - window.toggleDetail(identity, on: .keychain) - #expect(window.detailSelection(for: .keychain) == [key, identity]) - - window.toggleDetail(key, on: .keychain) - #expect(window.detailSelection(for: .keychain) == [identity]) - } - - @Test - func managerPagesRestoreIndependentSelections() { - let host = ContextPanelDetailID.host(UUID()) - let key = ContextPanelDetailID.key(UUID()) - let window = WindowModel(vaultDestination: .hosts) - - window.selectDetail(host, on: .hosts) - window.selectDetail(key, on: .keychain) - - #expect(window.activeDetailSelection == [host]) - window.openPage(.keychain) - #expect(window.activeDetailSelection == [key]) - } - - @Test - func hostFeatureStateOwnsGroupSelection() { - let first = HostsPageState() - let second = HostsPageState() - let group = UUID() - - first.selectedGroupID = group - - #expect(first.selectedGroupID == group) - #expect(second.selectedGroupID == nil) - } - - @Test - func snippetFeatureStateOwnsPackageSelection() { - let first = SnippetsPageState() - let second = SnippetsPageState() - let package = UUID() - - first.selectedPackageID = package - - #expect(first.selectedPackageID == package) - #expect(second.selectedPackageID == nil) - } - - @Test - func hostAttachedContentOverridesRememberedManagerSelection() { - let pageHostID = UUID() - let sftpHost = Host(name: "SFTP", host: "sftp.example") - let sftpWorkspace = Workspace.sftpSession(host: sftpHost) - let window = WindowModel(workspaces: [sftpWorkspace], vaultDestination: .hosts) - window.selectDetail(.host(pageHostID), on: .hosts) - - window.select(sftpWorkspace.id) - window.selectedWorkspaceID = sftpWorkspace.id - #expect(window.activeDetailSelection == [.host(sftpHost.id)]) - - window.openPage(.hosts) - #expect(window.activeDetailSelection == [.host(pageHostID)]) - } - - @Test - func pruningRemovesDeletedObjectsWithoutTouchingValidOnes() { - let host = Host(name: "Host", host: "host.example") - let key = SSHKeyRef(label: "Key") - let identity = Identity(name: "Identity", username: "user") - let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity]) - let window = WindowModel(vaultDestination: .hosts) - window.setDetailSelection([.host(host.id), .host(UUID())], for: .hosts) - window.setDetailSelection( - [.key(key.id), .identity(UUID()), .identity(identity.id)], - for: .keychain - ) - - #expect(window.activeDetailSelection(in: store) == [.host(host.id)]) - window.pruneDetailSelections(in: store) - - #expect(window.detailSelection(for: .hosts) == [.host(host.id)]) - #expect( - window.detailSelection(for: .keychain) - == [.key(key.id), .identity(identity.id)] - ) - } - - @Test - func sidebarModesAdaptToTheSelectedTopLevelContent() { - let host = Host(name: "SSH", host: "ssh.example") - let workspace = Workspace.sshSession(host: host) - let window = WindowModel(workspaces: [workspace]) - - #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) - #expect(window.isSidebarModeEnabled(.navigation)) - #expect(window.isSidebarModeEnabled(.history)) - - window.selectVault() - #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) - #expect(window.sidebarMode == .navigation) - - window.selectAssistant() - #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) - #expect(window.sidebarMode == .navigation) - } - - @Test - func sidebarModeIsSharedAcrossAllDestinations() { - 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.sidebarMode = .history - window.select(second.id) - #expect(window.sidebarMode == .history) - - window.selectVault() - #expect(window.sidebarMode == .history) - - window.selectAssistant() - #expect(window.sidebarMode == .history) - - window.sidebarMode = .navigation - window.openPage(.keychain) - #expect(window.selectedDestination == .vault) - #expect(window.vaultDestination == .keychain) - #expect(window.sidebarMode == .navigation) - } - - @Test - func globalModesRemainSelectedWithoutAnSSHFocus() { - let state = WindowSidebarState() - - for mode in [WindowSidebarState.Mode.snippets, .history] { - state.mode = mode - state.reconcileMode() - - #expect(state.mode == mode) - } - } - - @Test - func sftpEnablesAllFourSidebarModes() { - let workspace = Workspace.sftpSession( - host: Host(name: "Files", host: "files.example") - ) - let window = WindowModel(workspaces: [workspace]) - - #expect(window.availableSidebarModes == [.navigation, .assistant, .snippets, .history]) - #expect(window.isSidebarModeEnabled(.navigation)) - #expect(window.isSidebarModeEnabled(.assistant)) - #expect(window.isSidebarModeEnabled(.snippets)) - #expect(window.isSidebarModeEnabled(.history)) - } - - @Test - func showAssistantIsIdempotentAndOpensSidebar() { - let workspace = Workspace.sftpSession( - host: Host(name: "Files", host: "files.example") - ) - let window = WindowModel(workspaces: [workspace]) - window.showsSidebar = false - window.sidebarMode = .assistant - - window.showAssistant() - window.showAssistant() - - #expect(window.showsSidebar) - #expect(window.sidebarMode == .assistant) - } - - @Test - func topLevelSwitchDoesNotChangeSidebarVisibility() { - let workspace = Workspace.sshSession(host: Host(name: "SSH", host: "ssh.example")) - let window = WindowModel(workspaces: [workspace]) - - window.showsSidebar = false - window.selectVault() - window.selectAssistant() - window.select(workspace.id) - #expect(!window.showsSidebar) - - window.showsSidebar = true - window.selectVault() - #expect(window.showsSidebar) - } -} diff --git a/TermindTests/ListViewMigrationTests.swift b/TermindTests/ListViewMigrationTests.swift index 3ee05a3a..5ec9a2e4 100644 --- a/TermindTests/ListViewMigrationTests.swift +++ b/TermindTests/ListViewMigrationTests.swift @@ -130,10 +130,10 @@ struct ListViewMigrationTests { } @Test - func contextSidebarHistoryKeepsIdentityWhileInlineEditingChanges() { + func sidebarHistoryKeepsIdentityWhileInlineEditingChanges() { let entry = CommandHistoryEntry(hostID: UUID(), command: "pwd") - let normal = ContextSidebarListItem.history(entry, isNaming: false) - let naming = ContextSidebarListItem.history(entry, isNaming: true) + let normal = SidebarListItem.history(entry, isNaming: false) + let naming = SidebarListItem.history(entry, isNaming: true) #expect(normal.id == naming.id) #expect(normal != naming) @@ -149,7 +149,7 @@ struct ListViewMigrationTests { @Test func everyHistoryKindSharesOneCardHeight() { let hostID = UUID() - let items: [ContextSidebarListItem] = [ + let items: [SidebarListItem] = [ .history(CommandHistoryEntry(hostID: hostID, command: "pwd"), isNaming: false), .sftpHistory( SFTPHistoryEntry(hostID: hostID, kind: .directory, path: "/var/log") @@ -167,9 +167,9 @@ struct ListViewMigrationTests { @Test func historyIdentityIgnoresPresentationButNotTheRecord() { let host = Host(name: "web-01", host: "web-01.example") - let sameHost = ContextSidebarListItem.connection(host, address: "root@web-01.example") - let renamedAddress = ContextSidebarListItem.connection(host, address: "deploy@web-01.example") - let otherHost = ContextSidebarListItem.connection( + let sameHost = SidebarListItem.connection(host, address: "root@web-01.example") + let renamedAddress = SidebarListItem.connection(host, address: "deploy@web-01.example") + let otherHost = SidebarListItem.connection( Host(name: "db-01", host: "db-01.example"), address: "deploy@db-01.example" ) @@ -179,10 +179,10 @@ struct ListViewMigrationTests { } @Test - func contextSidebarSectionIdentityDoesNotDependOnItsTitle() { + func sidebarSectionIdentityDoesNotDependOnItsTitle() { let packageID = UUID() - let short = ContextSidebarListItem.section(.package(packageID), title: "Tools") - let renamed = ContextSidebarListItem.section( + let short = SidebarListItem.section(.package(packageID), title: "Tools") + let renamed = SidebarListItem.section( .package(packageID), title: "Operations", count: 3 ) @@ -192,8 +192,8 @@ struct ListViewMigrationTests { @Test func snippetSidebarExpansionIsIndependentAndRoundTrips() { - let first = ContextSidebarListItem.SectionID.package(UUID()) - let second = ContextSidebarListItem.SectionID.package(UUID()) + let first = SidebarListItem.SectionID.package(UUID()) + let second = SidebarListItem.SectionID.package(UUID()) var state = SnippetSidebarExpansionState() #expect(state.isExpanded(first)) diff --git a/TermindTests/VaultSelectionTests.swift b/TermindTests/VaultSelectionTests.swift new file mode 100644 index 00000000..31746d06 --- /dev/null +++ b/TermindTests/VaultSelectionTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing + +@testable import Termind + +@Suite +@MainActor +struct VaultSelectionTests { + @Test + func selectionsAreOrderedDeduplicatedAndScopedPerPage() { + let window = WindowModel() + let first = VaultSelectionID.host(UUID()) + let second = VaultSelectionID.host(UUID()) + + window.setVaultSelection([first, second, first, .key(UUID())], for: .hosts) + window.selectVaultItem(.key(UUID()), on: .keychain) + + #expect(window.vaultSelection(for: .hosts) == [first, second]) + #expect(window.vaultSelection(for: .keychain).count == 1) + } + + @Test + func toggleSupportsMixedCredentialSelection() { + let window = WindowModel() + let key = VaultSelectionID.key(UUID()) + let identity = VaultSelectionID.identity(UUID()) + + window.selectVaultItem(key, on: .keychain) + window.toggleVaultItem(identity, on: .keychain) + window.toggleVaultItem(key, on: .keychain) + + #expect(window.vaultSelection(for: .keychain) == [identity]) + } + + @Test + func pruningRemovesDeletedObjectsAndKeepsValidSelection() { + let host = Host(name: "Host", host: "host.example") + let store = HostLibraryModel(hosts: [host]) + let window = WindowModel() + window.setVaultSelection([.host(host.id), .host(UUID())], for: .hosts) + + window.pruneVaultSelections(in: store) + + #expect(window.vaultSelection(for: .hosts) == [.host(host.id)]) + } +} diff --git a/TermindTests/WindowSidebarTests.swift b/TermindTests/WindowSidebarTests.swift new file mode 100644 index 00000000..b33250fd --- /dev/null +++ b/TermindTests/WindowSidebarTests.swift @@ -0,0 +1,41 @@ +import Testing + +@testable import Termind + +@Suite +@MainActor +struct WindowSidebarTests { + @Test + func allDestinationsExposeNavigatorAndThreeToolSidebars() { + let window = WindowModel() + let expected: [WindowSidebarState.Mode] = [.navigation, .assistant, .snippets, .history] + + #expect(window.availableSidebarModes == expected) + window.selectVault() + #expect(window.availableSidebarModes == expected) + window.selectAssistant() + #expect(window.availableSidebarModes == expected) + } + + @Test + func sidebarModeAndVisibilityAreWindowScopedAndSurviveNavigation() { + let first = WindowModel() + let second = WindowModel() + + first.sidebarMode = .history + first.showsSidebar = false + first.selectAssistant() + + #expect(first.sidebarMode == .history) + #expect(!first.showsSidebar) + #expect(second.sidebarMode == .navigation) + #expect(second.showsSidebar) + } + + @Test + func statusBarBelongsOnlyToWorkPanes() { + #expect(PaneKind.sshSession.showsStatusBar) + #expect(PaneKind.sftpSession.showsStatusBar) + #expect(!PaneKind.empty.showsStatusBar) + } +} diff --git a/TermindTests/WindowStateTests.swift b/TermindTests/WindowStateTests.swift index 4bc9d567..d4ab2e30 100644 --- a/TermindTests/WindowStateTests.swift +++ b/TermindTests/WindowStateTests.swift @@ -219,7 +219,7 @@ struct WindowStateTests { } @Test - func forkTitlesUseTheLowestFreeTabStyleNumber() { + func forkTitlesUseTheLowestFreeWorkspaceStyleNumber() { let source = ConversationModel() source.customTitle = "Question" let store = AssistantConversationStore(conversations: [source]) @@ -484,7 +484,7 @@ struct WindowStateTests { } /// The reported leak: session context must belong to the sidebar, so a - /// conversation promoted out of it arrives in the Assistant tab carrying + /// conversation promoted out of it arrives in the Assistant destination carrying /// nothing about a host it can no longer see. @Test func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() { @@ -539,7 +539,7 @@ struct WindowStateTests { /// 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 tab. + /// Assistant destination. @Test func openingASidebarConversationLeavesItUnmarkedByTheFocusedPane() { let host = Host(name: "Server", host: "server.example") @@ -718,7 +718,7 @@ struct WindowStateTests { } @Test - func hostEditorIsVaultNavigationAndCloseTabCannotCloseHiddenWork() { + func hostEditorIsVaultNavigationAndCloseWorkspaceCannotCloseHiddenWork() { let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace]) @@ -747,17 +747,17 @@ struct WindowStateTests { } @Test - func windowsKeepPresentationAndContextPanelStateIsolated() { + func windowsKeepPresentationAndVaultSelectionIsolated() { let first = WindowModel(vaultDestination: .hosts) let second = WindowModel(vaultDestination: .hosts) let host = UUID() first.hostChooser = .connect - first.selectDetail(.host(host), on: .hosts) + first.selectVaultItem(.host(host), on: .hosts) #expect(first.hostChooser == .connect) #expect(second.hostChooser == nil) - #expect(second.detailSelection(for: .hosts).isEmpty) + #expect(second.vaultSelection(for: .hosts).isEmpty) } @Test @@ -772,23 +772,6 @@ struct WindowStateTests { #expect(state.sheet == nil) } - @Test - func contextPanelOverrideExpiresWhenFocusLeavesItsPane() { - let firstHost = Host(name: "First", host: "first.example") - let secondHost = Host(name: "Second", host: "second.example") - let first = Workspace.sshSession(host: firstHost) - let second = Workspace.sshSession(host: secondHost) - let window = WindowModel(workspaces: [first, second]) - window.selectedWorkspaceID = first.id - - window.sidebar.show(.snippet(UUID()), scopedTo: first.focusedPane) - #expect(window.activeDetailSelection.first?.kind == ContextPanelDetailID.Kind.snippet) - - window.selectedWorkspaceID = second.id - window.sidebar.focusedPaneDidChange(to: second.focusedPane) - #expect(window.activeDetailSelection == [.host(secondHost.id)]) - } - @Test func featureRoutesReplaceRatherThanStack() { let window = WindowModel(vaultDestination: .hosts) @@ -815,7 +798,7 @@ struct WindowStateTests { } @Test - func sessionsMixSSHAndSFTPInTabAndPaneOrder() { + func sessionsMixSSHAndSFTPInWorkspaceAndPaneOrder() { let firstHost = Host(name: "First", host: "first.example") let secondHost = Host(name: "Second", host: "second.example") let mixed = Workspace.sshSession(host: firstHost) @@ -868,7 +851,7 @@ struct WindowStateTests { } @Test - func appRoutesHostsToSSHAndSFTPSessionsForTabsAndWorkspaces() { + func appRoutesHostsToSSHAndSFTPSessionsForWorkspaces() { let first = Host(name: "First", host: "first.example") let second = Host(name: "Second", host: "second.example") let store = HostLibraryModel(hosts: [first, second]) @@ -914,7 +897,7 @@ struct WindowStateTests { } @Test - func terminalPathOpensSFTPInANewTabOrTheCurrentWorkspace() { + func terminalPathOpensSFTPInANewWorkspaceOrTheCurrentWorkspace() { var host = Host(name: "Files", host: "files.example") host.sftpSessionInitialPath = "/configured" let sshWorkspace = Workspace.sshSession(host: host) @@ -942,7 +925,7 @@ struct WindowStateTests { } @Test - func sftpPathOpensSSHInANewTabOrTheCurrentWorkspace() throws { + func sftpPathOpensSSHInANewWorkspaceOrTheCurrentWorkspace() throws { let host = Host(name: "Shell", host: "shell.example") let sftpWorkspace = Workspace.sftpSession(host: host, initialPath: "/configured") let window = WindowModel(workspaces: [sftpWorkspace]) @@ -1034,7 +1017,7 @@ struct WindowStateTests { } @Test - func normalRemoteExitClosesLastPaneAndSelectsNeighboringTab() { + func normalRemoteExitClosesLastPaneAndSelectsNeighboringWorkspace() { let host = Host(name: "Host", host: "host.example") let sshSessionTab = Workspace.sshSession(host: host) let sshSession = sshSessionTab.focusedPane.sshSession! diff --git a/TermindTests/WindowToolbarFocusTests.swift b/TermindTests/WindowToolbarFocusTests.swift deleted file mode 100644 index 4faed490..00000000 --- a/TermindTests/WindowToolbarFocusTests.swift +++ /dev/null @@ -1,248 +0,0 @@ -#if canImport(AppKit) - import AppKit - import SwiftUI - import Testing - - @testable import Termind - - private nonisolated let hasActiveDisplay: Bool = { - var count: UInt32 = 0 - guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false } - return count > 0 - }() - - private final class FocusableView: NSView { - override var acceptsFirstResponder: Bool { true } - - override func mouseDown(with event: NSEvent) { - window?.makeFirstResponder(self) - } - } - - @MainActor - private final class TextBox { - var value = "" - } - - /// Uses a real responder chain: the regression is specifically AppKit moving - /// first responder while SwiftUI updates the pane being searched. - @Suite - @MainActor - struct WindowToolbarFocusTests { - private func makeField() -> (NSSearchField, NativeSearchField.Coordinator, NSWindow) { - let box = TextBox() - let representable = NativeSearchField( - text: Binding(get: { box.value }, set: { box.value = $0 }), - placeholder: "Search" - ) - let coordinator = representable.makeCoordinator() - let searchField = NSSearchField( - frame: NSRect(x: 0, y: 0, width: 220, height: 24) - ) - searchField.delegate = coordinator - coordinator.attach(to: searchField) - - let content = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 240)) - content.addSubview(searchField) - let window = NSWindow( - contentRect: content.frame, - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.contentView = content - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - return (searchField, coordinator, window) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func escapeGivesTheKeyboardUp() throws { - let box = TextBox() - var cancelled = false - let representable = NativeSearchField( - text: Binding(get: { box.value }, set: { box.value = $0 }), - placeholder: "Search", - cancel: { cancelled = true } - ) - let coordinator = representable.makeCoordinator() - let searchField = NSSearchField( - frame: NSRect(x: 0, y: 0, width: 220, height: 24) - ) - searchField.delegate = coordinator - coordinator.attach(to: searchField) - - let content = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 240)) - content.addSubview(searchField) - let thief = FocusableView(frame: NSRect(x: 0, y: 40, width: 100, height: 20)) - content.addSubview(thief) - let window = NSWindow( - contentRect: content.frame, - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.contentView = content - window.makeKeyAndOrderFront(nil) - - coordinator.focusFromCommand(searchField) - let editor = try #require(searchField.currentEditor() as? NSTextView) - - let handled = coordinator.control( - searchField, - textView: editor, - doCommandBy: #selector(NSResponder.cancelOperation(_:)) - ) - #expect(handled) - #expect(cancelled) - - #expect(window.makeFirstResponder(thief)) - #expect(window.firstResponder === thief) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func recreatedFieldInheritsTheExistingKeyboardClaim() async throws { - let box = TextBox() - let representable = NativeSearchField( - text: Binding(get: { box.value }, set: { box.value = $0 }), - placeholder: "Search", - maintainsKeyboardFocus: true - ) - let coordinator = representable.makeCoordinator() - let searchField = NSSearchField( - frame: NSRect(x: 0, y: 0, width: 220, height: 24) - ) - searchField.stringValue = "abc" - searchField.delegate = coordinator - coordinator.attach(to: searchField) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.contentView?.addSubview(searchField) - window.makeKeyAndOrderFront(nil) - - coordinator.restoreKeyboardClaimIfNeeded(to: searchField) - try await Task.sleep(for: .milliseconds(20)) - - #expect(searchField.currentEditor() != nil) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func commandFocusWorksAgainAfterEscape() throws { - let box = TextBox() - let representable = NativeSearchField( - text: Binding(get: { box.value }, set: { box.value = $0 }), - placeholder: "Search", - cancel: {} - ) - let coordinator = representable.makeCoordinator() - let searchField = NSSearchField( - frame: NSRect(x: 0, y: 0, width: 220, height: 24) - ) - searchField.delegate = coordinator - coordinator.attach(to: searchField) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.contentView?.addSubview(searchField) - window.makeKeyAndOrderFront(nil) - - coordinator.focusFromCommand(searchField) - let firstEditor = try #require(searchField.currentEditor() as? NSTextView) - _ = coordinator.control( - searchField, - textView: firstEditor, - doCommandBy: #selector(NSResponder.cancelOperation(_:)) - ) - - coordinator.focusFromCommand(searchField) - #expect(searchField.currentEditor() != nil) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func mouseClickGivesTheKeyboardUp() throws { - let (searchField, coordinator, window) = makeField() - let thief = FocusableView(frame: NSRect(x: 0, y: 40, width: 100, height: 20)) - window.contentView?.addSubview(thief) - coordinator.focusFromCommand(searchField) - #expect(searchField.currentEditor() != nil) - - let event = try #require( - NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 20, y: 50), - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1 - ) - ) - coordinator.handleUserEvent(event) - #expect(window.makeFirstResponder(thief)) - - #expect(window.firstResponder === thief) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func inactiveWindowDoesNotKeepTheKeyboardClaim() { - let (searchField, coordinator, window) = makeField() - let thief = FocusableView(frame: NSRect(x: 0, y: 40, width: 100, height: 20)) - window.contentView?.addSubview(thief) - coordinator.focusFromCommand(searchField) - - let otherWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - otherWindow.makeKeyAndOrderFront(nil) - #expect(!window.isKeyWindow) - - #expect(window.makeFirstResponder(thief)) - #expect(window.firstResponder === thief) - } - - @Test(.disabled(if: !hasActiveDisplay, "needs a window server")) - func remountingAFocusedTerminalDoesNotReassertFirstResponder() throws { - let (searchField, coordinator, window) = makeField() - let sshSession = SSHSessionModel(hostID: UUID(), title: "web-01") - let mount = TerminalMountView( - frame: NSRect(x: 0, y: 40, width: 320, height: 160) - ) - window.contentView?.addSubview(mount) - - sshSession.terminalViewOwner.mount( - in: mount, - state: sshSession.terminalState, - isVisible: true, - requestsFocus: true - ) - - coordinator.focusFromCommand(searchField) - let editor = try #require(searchField.currentEditor()) - - for _ in 0..<5 { - sshSession.terminalViewOwner.mount( - in: mount, - state: sshSession.terminalState, - isVisible: true, - requestsFocus: true - ) - } - - #expect(window.firstResponder === editor) - #expect(!sshSession.terminalViewOwner.hasKeyboardFocusForTesting) - } - } -#endif