Refactor workspace and sidebar architecture

This commit is contained in:
Kaede Akino committed 2026-08-29 09:03:25 +08:00
1 parent d669c2efac
commit 1a226daae4
57 files changed
+828 -1077

No files matched your search

+11 -1
View File
@@ -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
+24 -29
View File
@@ -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 {
+1 -1
View File
@@ -25,7 +25,7 @@ struct RootView: View {
ZStack {
SidebarMaterialBackground()
.ignoresSafeArea()
SidebarView(detailCatalog: .standard(store: app.hostLibrary))
WindowSidebarView()
}
.navigationSplitViewColumnWidth(
min: Metrics.Sidebar.min,
@@ -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
@@ -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()
}
}
@@ -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<SSHHostKeyIdentity>
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 {}
@@ -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
}
}
@@ -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()
@@ -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
@@ -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
@@ -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)
@@ -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()
}
}
@@ -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
@@ -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<SSHHostKeyIdentity> {
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) }
}
@@ -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?()
}
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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(
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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)
}
}
@@ -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 = ""
}
+20 -8
View File
@@ -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
@@ -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 {
+3 -3
View File
@@ -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
@@ -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)
}
}
@@ -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
@@ -1,68 +0,0 @@
import Foundation
protocol ContextPanelDetailProvider {
var kind: String { get }
var availableIDs: Set<ContextPanelDetailID> { get }
}
struct AnyContextPanelDetailProvider: ContextPanelDetailProvider {
let kind: String
private let available: () -> Set<ContextPanelDetailID>
init<P: ContextPanelDetailProvider>(_ provider: P) {
kind = provider.kind
available = { provider.availableIDs }
}
var availableIDs: Set<ContextPanelDetailID> { available() }
}
struct ContextPanelDetailCatalog {
private let providers: [AnyContextPanelDetailProvider]
init(_ providers: [AnyContextPanelDetailProvider]) {
self.providers = providers
assert(Set(providers.map(\.kind)).count == providers.count)
}
var availableIDs: Set<ContextPanelDetailID> {
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<ContextPanelDetailID> { Set(store.hosts.map { .host($0.id) }) }
}
private struct KeyContextPanelDetailProvider: ContextPanelDetailProvider {
let store: HostLibraryModel
let kind = ContextPanelDetailID.Kind.key
var availableIDs: Set<ContextPanelDetailID> { Set(store.keys.map { .key($0.id) }) }
}
private struct IdentityContextPanelDetailProvider: ContextPanelDetailProvider {
let store: HostLibraryModel
let kind = ContextPanelDetailID.Kind.identity
var availableIDs: Set<ContextPanelDetailID> {
Set(store.identities.map { .identity($0.id) })
}
}
private struct SnippetContextPanelDetailProvider: ContextPanelDetailProvider {
let store: HostLibraryModel
let kind = ContextPanelDetailID.Kind.snippet
var availableIDs: Set<ContextPanelDetailID> {
Set(store.snippets.map { .snippet($0.id) })
}
}
@@ -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 {
+3 -3
View File
@@ -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 {
+7 -7
View File
@@ -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<Host.ID> {
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<Host.ID>) {
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
}
@@ -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<ContextPanelDetailID> {
Set(window.detailSelection(for: .keychain))
private var selection: Set<VaultSelectionID> {
Set(window.vaultSelection(for: .keychain))
}
private var isEmpty: Bool {
@@ -82,11 +82,11 @@ struct KeychainPageView: ManagedCardPage {
]
}
var managedSelection: ManagedCardSelection<ContextPanelDetailID> {
var managedSelection: ManagedCardSelection<VaultSelectionID> {
ManagedCardSelection(selected: { selection }, update: setSelection)
}
var managedCapabilities: ManagedCardCapabilities<SectionID, ContextPanelDetailID> {
var managedCapabilities: ManagedCardCapabilities<SectionID, VaultSelectionID> {
ManagedCardCapabilities(
selection: .multiple,
marqueeSelection: true,
@@ -200,13 +200,13 @@ struct KeychainPageView: ManagedCardPage {
// MARK: - Selection
private func setSelection(_ selected: Set<ContextPanelDetailID>) {
let ordered: [ContextPanelDetailID] =
private func setSelection(_ selected: Set<VaultSelectionID>) {
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
+1 -1
View File
@@ -648,7 +648,7 @@ struct SFTPBrowserView: View {
}
}
.animation(
Motion.tabSwitch,
Motion.workspaceSwitch,
value: isRemoteDropTargeted || isExternalFileDropTargeted
)
.onChange(of: externalFileDrop?.id) {
+22 -18
View File
@@ -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,
+1 -1
View File
@@ -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
@@ -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<ContextSidebarListItem>
typealias Renderer = @MainActor (ContextSidebarListItem) -> AnyView
private struct SidebarNativeList: NSViewRepresentable {
typealias NSViewType = SidebarListContainer
typealias List = ListView<SidebarListItem>
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<ContextSidebarListItem>
final class SidebarListContainer: NSView {
let listView: ListView<SidebarListItem>
var blankAreaMenu: (() -> [NativeMenuEntry])?
init(listView: ListView<ContextSidebarListItem>) {
init(listView: ListView<SidebarListItem>) {
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<Trailing: View>: 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))
},
@@ -69,7 +69,7 @@ struct SnippetsPageView: ManagedCardPage {
private var selection: Set<Snippet.ID> {
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]) {
-214
View File
@@ -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<ContextPanelDetailID> = []
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<ContextPanelDetailID>) {
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?
}
+14 -13
View File
@@ -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<Void, Never>?
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(
@@ -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)
@@ -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<VaultSelectionID> = []
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<VaultSelectionID>) {
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.
+4 -4
View File
@@ -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
)
@@ -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
@@ -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):
@@ -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<ContextPanelDetailID> {
private static func availableVaultSelections(in store: HostLibraryModel) -> Set<VaultSelectionID> {
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
@@ -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?
}
@@ -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 {
@@ -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(
@@ -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)
}
}
+12 -12
View File
@@ -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))
+46
View File
@@ -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)])
}
}
+41
View File
@@ -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)
}
}
+12 -29
View File
@@ -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!
-248
View File
@@ -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