Refactor workspace and sidebar architecture
This commit is contained in:
1 parent
d669c2efac
commit
1a226daae4
57 files changed
+828
-1077
No files matched your search
+11
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -648,7 +648,7 @@ struct SFTPBrowserView: View {
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
Motion.tabSwitch,
|
||||
Motion.workspaceSwitch,
|
||||
value: isRemoteDropTargeted || isExternalFileDropTargeted
|
||||
)
|
||||
.onChange(of: externalFileDrop?.id) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
-54
@@ -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]) {
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+1
-1
@@ -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):
|
||||
File renamed without changes.
+40
-56
@@ -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?
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -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 {
|
||||
File renamed without changes.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user