1640 lines
65 KiB
Swift
1640 lines
65 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
enum SessionOpenPlacement: Equatable {
|
|
case newWorkspace
|
|
case currentWorkspace
|
|
}
|
|
|
|
protocol WindowRemoteTitleSink: AnyObject {
|
|
func windowRemoteTitleDidChange(_ title: String?)
|
|
}
|
|
|
|
/// The state of one window: its workspaces, selection, and shared left sidebar.
|
|
///
|
|
/// One of these per window rather than one per app, so two windows can hold
|
|
/// different sets of workspaces. Menu commands reach the right instance through
|
|
/// `FocusedValues.window`.
|
|
@Observable
|
|
final class WindowModel {
|
|
let id = UUID()
|
|
private(set) var isClosed = false
|
|
let workspaceDeck: WorkspaceDeckModel
|
|
var workspaces: [Workspace] {
|
|
get { workspaceDeck.workspaces }
|
|
set { workspaceDeck.workspaces = newValue }
|
|
}
|
|
|
|
var unreadCompletionCount: Int {
|
|
workspaces.reduce(0) { $0 + $1.unreadCompletionCount }
|
|
}
|
|
/// The top-level shell has Vault, Assistant, Settings, and dynamic SSH/SFTP
|
|
/// Workspaces. This is the single source of truth for workspace selection.
|
|
var selectedDestination: WindowDestination = .vault {
|
|
didSet {
|
|
if selectedDestination != oldValue {
|
|
publishRemoteWindowTitle()
|
|
}
|
|
if selectedDestination != .assistant {
|
|
assistantReturnDestination = selectedDestination
|
|
}
|
|
reconcileSidebarMode()
|
|
commandFocusChanged?()
|
|
}
|
|
}
|
|
@ObservationIgnored weak var remoteWindowTitleSink: (any WindowRemoteTitleSink)?
|
|
/// Where Navigator returns after the Assistant temporarily replaces the
|
|
/// detail surface. Assistant has no row inside the Navigator that could
|
|
/// retain the previous selection for us.
|
|
private var assistantReturnDestination: WindowDestination = .vault
|
|
/// Settings is a reversible trip from the content in front. Its own route is
|
|
/// window-scoped so two windows can configure shared preferences independently.
|
|
private var settingsReturnDestination: WindowDestination = .vault
|
|
weak var metricsModel: ServerMetricsModel?
|
|
var pendingMetricsHostIDs: [UUID]?
|
|
var pendingMetricsEditableHostIDs: Set<UUID> = []
|
|
var metricsHostID: UUID? {
|
|
didSet {
|
|
if metricsHostID != oldValue { metricsDetailPage = .status }
|
|
}
|
|
}
|
|
var metricsDetailPage = MetricsDetailPage.status
|
|
var metricsDetailWidth: CGFloat = 0 {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
var showsCompactMetricsDetail: Bool {
|
|
guard selectedDestination == .metrics, metricsDetailWidth > 0,
|
|
!MetricsDetailPage.showsBothColumns(width: metricsDetailWidth),
|
|
let server = metricsModel?.servers.first(where: { $0.id == metricsHostID })
|
|
else { return false }
|
|
return !server.showsConnectionJourney
|
|
}
|
|
var metricsQuery = ""
|
|
var metricsProcessQuery = "" {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
var metricsSelection: Set<UUID> = []
|
|
var settingsRoute: SettingsRoute = .index
|
|
var terminalThemeQueries: [TerminalCustomConfigurationScheme: String] = [:]
|
|
var vaultDestination: VaultDestination = .welcome
|
|
var vaultWorkRoute: VaultWorkRoute?
|
|
let assistantLibrary: AssistantLibraryModel
|
|
/// Native transcript views survive destination and conversation switches,
|
|
/// while remaining window-local and bounded in memory.
|
|
let transcriptCache = TranscriptListCache()
|
|
let sidebar = WindowSidebarState()
|
|
let presentations = WindowPresentationState()
|
|
let dragState = WorkspaceDragState()
|
|
/// Query and confirmation state for the three manager pages, kept here so a
|
|
/// half-typed search survives a trip to another page — see `VaultPageStates`.
|
|
let vaultPages = VaultPageStates()
|
|
/// What the titlebar Find field holds while the Assistant is in front.
|
|
/// Deliberately window-local and never persisted: a search is a way of
|
|
/// looking at the list, not a property of it.
|
|
var assistantQuery = ""
|
|
/// Archived chats are a destination, not a third section in live history.
|
|
/// This remains window-local so two windows may independently manage the
|
|
/// archive and continue a conversation.
|
|
var isAssistantArchivePresented = false
|
|
var connectionCoordinator: RemoteConnectionCoordinator? {
|
|
didSet {
|
|
guard let connectionCoordinator else { return }
|
|
for workspace in workspaces {
|
|
workspace.configureConnections(using: connectionCoordinator)
|
|
}
|
|
}
|
|
}
|
|
weak var commandHistoryStore: CommandHistoryModel? {
|
|
didSet {
|
|
guard let commandHistoryStore else { return }
|
|
for workspace in workspaces { workspace.configureCommandHistory(using: commandHistoryStore) }
|
|
}
|
|
}
|
|
weak var sftpHistoryStore: SFTPHistoryModel? {
|
|
didSet {
|
|
guard let sftpHistoryStore else { return }
|
|
for workspace in workspaces { workspace.configureSFTPHistory(using: sftpHistoryStore) }
|
|
}
|
|
}
|
|
var assistantCommands: AssistantConversationCommands?
|
|
var assistantOperationError: String?
|
|
var workspaceTemplateNotice: String?
|
|
var pendingExternalConnection: RemoteConnectionURL?
|
|
var externalConnectionError: String?
|
|
var hostStore: HostLibraryModel? {
|
|
didSet {
|
|
refreshSessionContext()
|
|
}
|
|
}
|
|
@ObservationIgnored var workspacePreferences: () -> WorkspacePreferences = { WorkspacePreferences() }
|
|
|
|
/// What is being dragged right now, from the workspace bar or from a pane.
|
|
///
|
|
/// Held on the model because every drop target has to decide things *during*
|
|
/// the drag: whether to draw itself without the thing in flight, where a
|
|
/// ghost slot should open, and whether the drop will move a pane or fold in
|
|
/// a whole workspace. The pasteboard payload resolves asynchronously, one drop too
|
|
/// late to answer any of them.
|
|
///
|
|
/// Pane sources clear this when their native session ends. Workspace sources
|
|
/// use the List's session callback on macOS 26 and later; older systems clear
|
|
/// any leftover workspace preview on the next click or drag.
|
|
var dragging: DragPayload? {
|
|
get { dragState.payload }
|
|
set { dragState.payload = newValue }
|
|
}
|
|
|
|
/// 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 setVaultSelection(_ details: [VaultSelectionID], for page: VaultPage) {
|
|
vaultPages.selection.set(details, for: 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 toggleVaultItem(_ detail: VaultSelectionID, on page: VaultPage) {
|
|
vaultPages.selection.toggle(detail, on: page)
|
|
}
|
|
|
|
func clearVaultSelection(for page: VaultPage) {
|
|
vaultPages.selection.clear(page)
|
|
}
|
|
|
|
/// Removes records deleted out from under a remembered selection.
|
|
func pruneVaultSelections(in store: HostLibraryModel) {
|
|
vaultPages.selection.prune(available: Self.availableVaultSelections(in: store))
|
|
}
|
|
|
|
func pruneVaultSelections(in store: CredentialLibraryModel) {
|
|
let available = Set(store.keys.map { VaultSelectionID.key($0.id) })
|
|
.union(store.identities.map { .identity($0.id) })
|
|
vaultPages.selection.prune(available: available, pages: [.keychain])
|
|
}
|
|
|
|
func pruneVaultSelections(in store: SnippetLibraryModel) {
|
|
vaultPages.selection.prune(
|
|
available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }), pages: [.snippets]
|
|
)
|
|
}
|
|
|
|
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) })
|
|
.union(store.portForwardings.map { .forwarding($0.id) })
|
|
.union(store.snippets.map { .snippet($0.id) })
|
|
.union(store.knownHosts.map { .knownHost($0.id) })
|
|
}
|
|
|
|
typealias SidebarMode = WindowSidebarState.Mode
|
|
|
|
var showsSidebar: Bool {
|
|
get { sidebar.isPresented }
|
|
set { sidebar.isPresented = newValue }
|
|
}
|
|
|
|
var availableSidebarModes: [SidebarMode] {
|
|
SidebarMode.allCases.filter(isSidebarModeEnabled)
|
|
}
|
|
|
|
var sidebarMode: SidebarMode {
|
|
get { sidebar.mode }
|
|
set {
|
|
guard isSidebarModeEnabled(newValue) else { return }
|
|
sidebar.mode = newValue
|
|
}
|
|
}
|
|
|
|
func isSidebarModeEnabled(_ mode: SidebarMode) -> Bool {
|
|
mode != .history || (focusedSerialSession == nil && focusedTelnetSession == nil)
|
|
}
|
|
|
|
func reconcileSidebarMode() {
|
|
if !isSidebarModeEnabled(sidebarMode) {
|
|
sidebar.mode = .snippets
|
|
}
|
|
}
|
|
|
|
/// Applies a Sidebar mode chosen by the user, including the two mode-rail
|
|
/// buttons that also act as top-level navigation affordances.
|
|
///
|
|
/// Direct `sidebarMode` assignment deliberately remains presentation-only
|
|
/// for restoration, screenshots, and other programmatic configuration.
|
|
func activateSidebarMode(_ mode: SidebarMode) {
|
|
guard isSidebarModeEnabled(mode) else { return }
|
|
sidebarMode = mode
|
|
|
|
switch mode {
|
|
case .assistant:
|
|
// Inside a Workspace this is its compact, context-aware Assistant.
|
|
// Everywhere else the same control opens the full Assistant route.
|
|
if selectedWorkspace == nil {
|
|
selectAssistant()
|
|
}
|
|
case .navigation:
|
|
if selectedDestination == .assistant {
|
|
restoreAssistantReturnDestination()
|
|
}
|
|
case .snippets, .history:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func restoreAssistantReturnDestination() {
|
|
switch assistantReturnDestination {
|
|
case .workspace(let id):
|
|
if workspaces.contains(where: { $0.id == id }) {
|
|
select(id)
|
|
} else {
|
|
selectVault()
|
|
}
|
|
case .vault:
|
|
selectVault()
|
|
case .assistant:
|
|
// Defensive only: the remembered destination is updated exclusively
|
|
// from non-Assistant routes.
|
|
selectVault()
|
|
case .metrics:
|
|
selectedDestination = .metrics
|
|
case .settings:
|
|
selectedDestination = .settings
|
|
}
|
|
}
|
|
|
|
/// Opens the shared column and selects the current Workspace's assistant.
|
|
/// Repeating the command is intentionally idempotent; hiding the column is
|
|
/// the native Toggle Sidebar command's job.
|
|
func showAssistant() {
|
|
guard selectedWorkspace != nil else { return }
|
|
refreshSessionContext()
|
|
sidebar.isPresented = true
|
|
sidebarMode = .assistant
|
|
}
|
|
|
|
var hostChooser: HostChooserPurpose? {
|
|
get { presentations.hostChooser }
|
|
set { presentations.hostChooser = newValue }
|
|
}
|
|
|
|
var isQuickOpenPresented: Bool {
|
|
get { presentations.isQuickOpenPresented }
|
|
set {
|
|
presentations.isQuickOpenPresented = newValue
|
|
if newValue { isToolbarFindFocused = false }
|
|
}
|
|
}
|
|
|
|
/// 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 session cannot be revived.
|
|
private var reopenStack: [Workspace] {
|
|
get { workspaceDeck.reopenStack }
|
|
set { workspaceDeck.reopenStack = newValue }
|
|
}
|
|
|
|
var commandFocusChanged: (() -> Void)?
|
|
var configureCommandNotifications: ((Pane) -> Void)?
|
|
|
|
var recordConnectionHistory: ((Host.ID, ConnectionHistoryEntry.Kind) -> ConnectionHistoryEntry.ID?)?
|
|
var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
|
|
|
|
init(
|
|
workspaces: [Workspace] = [],
|
|
vaultDestination: VaultDestination = .welcome,
|
|
assistantLibrary: AssistantLibraryModel = AssistantLibraryModel()
|
|
) {
|
|
self.workspaceDeck = WorkspaceDeckModel(workspaces: workspaces)
|
|
self.vaultDestination = vaultDestination
|
|
self.assistantLibrary = assistantLibrary
|
|
selectedDestination = workspaces.first.map { .workspace($0.id) } ?? .vault
|
|
// Property observers do not run while an instance is being initialized.
|
|
assistantReturnDestination = selectedDestination
|
|
settingsReturnDestination = selectedDestination
|
|
for workspace in workspaces {
|
|
workspace.lifecycleRouter = self
|
|
}
|
|
}
|
|
|
|
// MARK: - Selection
|
|
|
|
var selectedWorkspace: Workspace? {
|
|
guard case .workspace(let selectedWorkspaceID) = selectedDestination else { return nil }
|
|
return workspaces.first { $0.id == selectedWorkspaceID }
|
|
}
|
|
|
|
var selectedWorkspaceID: Workspace.ID? {
|
|
get {
|
|
guard case .workspace(let id) = selectedDestination else { return nil }
|
|
return id
|
|
}
|
|
set {
|
|
selectedDestination = newValue.map(WindowDestination.workspace) ?? .vault
|
|
}
|
|
}
|
|
|
|
/// The only pane that application-wide find commands may touch. A Workspace
|
|
/// can contain several terminals, so routing to "any session in the workspace"
|
|
/// would make ⌘F search a buffer other than the one under the caret.
|
|
var focusedSSHSession: SSHSessionModel? {
|
|
return selectedWorkspace?.focusedPane.sshSession
|
|
}
|
|
|
|
var focusedSFTPChannel: SFTPChannelModel? {
|
|
selectedWorkspace?.focusedPane.sftpChannel
|
|
}
|
|
|
|
var focusedSerialSession: SerialSessionModel? {
|
|
selectedWorkspace?.focusedPane.serialSession
|
|
}
|
|
|
|
var focusedTelnetSession: TelnetSessionModel? {
|
|
selectedWorkspace?.focusedPane.telnetSession
|
|
}
|
|
|
|
var canFindInTerminal: Bool {
|
|
focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil
|
|
}
|
|
|
|
var canGenerateTerminalCommand: Bool { focusedSSHSession != nil }
|
|
|
|
func generateTerminalCommand(using controller: CommandGeneratorController) {
|
|
selectedWorkspace?.generateFocusedCommand(using: controller)
|
|
}
|
|
|
|
// MARK: - Find
|
|
|
|
/// Bumped by ⌘F. The titlebar field watches it and takes first responder,
|
|
/// so pressing ⌘F while the field is already showing refocuses rather than
|
|
/// doing nothing.
|
|
private(set) var findFocusRequest: UInt64 = 0
|
|
|
|
/// Bumped by ⌘F while Welcome is in front. Welcome owns a large search
|
|
/// field in its content instead of borrowing the titlebar Find field.
|
|
private(set) var welcomeSearchFocusRequest: UInt64 = 0
|
|
|
|
/// True while the titlebar Find field owns the keyboard.
|
|
///
|
|
/// The field is outside every pane, but what it filters is inside one — so
|
|
/// typing in it changes state the pane observes, and a pane that claims
|
|
/// first responder whenever it is the focused pane takes the caret back
|
|
/// after each keystroke. Panes read this and stand down while it is set.
|
|
///
|
|
/// Level, not edge. A pane has to consult it every time it is about to hold
|
|
/// focus, not only when this flips: the caret is lost on *each* keystroke,
|
|
/// and a stand-down that fires once cannot answer the second one.
|
|
var isToolbarFindFocused = false {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
|
|
private func revealMetricsProcessesForFind() {
|
|
if showsCompactMetricsDetail { metricsDetailPage = .processes }
|
|
}
|
|
|
|
/// Focuses the titlebar Find field. Installed by the field itself while it
|
|
/// is mounted, and left pointing at a field that has gone away otherwise —
|
|
/// harmlessly, since the closure holds it weakly and `focusToolbarFind`
|
|
/// checks there is something to search first.
|
|
///
|
|
/// ⌘F does not go through `findFocusRequest` alone. That counter only
|
|
/// reaches the field if SwiftUI delivers an update to the toolbar item
|
|
/// holding it, which is not a refresh this code owns.
|
|
@ObservationIgnored var focusToolbarFindField: (() -> Void)?
|
|
|
|
/// What the window's one Find field is pointed at, decided by the content in
|
|
/// front. A split Workspace resolves to its *focused* pane — "any session in
|
|
/// the workspace" would make ⌘F search a buffer other than the one under the
|
|
/// caret.
|
|
var findKind: WindowToolbarFindKind? {
|
|
switch selectedDestination {
|
|
case .assistant:
|
|
return .assistant
|
|
case .metrics:
|
|
return metricsHostID == nil ? .metrics : .processes
|
|
case .settings:
|
|
if case .terminalThemes = settingsRoute { return .terminalThemes }
|
|
return nil
|
|
case .workspace:
|
|
if focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil {
|
|
return .terminal
|
|
}
|
|
if focusedSFTPChannel != nil { return .sftp }
|
|
return nil
|
|
case .vault:
|
|
switch vaultDestination {
|
|
// Welcome has its own field, and it is the page's whole point.
|
|
case .welcome: return nil
|
|
case .hosts: return .hosts
|
|
case .keychain: return .keychain
|
|
case .portForwarding: return .portForwarding
|
|
case .snippets: return .snippets
|
|
case .knownHosts: return .knownHosts
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hasWelcomeSearch: Bool {
|
|
guard case .vault = selectedDestination else { return false }
|
|
return vaultDestination == .welcome
|
|
}
|
|
|
|
var canFindInMainContent: Bool { hasWelcomeSearch || findKind != nil }
|
|
|
|
/// ⌘F. Focuses the titlebar field, and for a terminal also starts Ghostty's
|
|
/// search again on a query retained from last time.
|
|
///
|
|
/// The panes are stood down *first*. Everything after this line changes
|
|
/// state a pane observes, so by the time the resulting update reaches one it
|
|
/// must already know not to claim the keyboard — otherwise ⌘F focuses the
|
|
/// field and the rebuild it caused takes the caret straight back, which is
|
|
/// how the Find menu item came to be clickable and yet do nothing.
|
|
func focusToolbarFind() {
|
|
if hasWelcomeSearch {
|
|
welcomeSearchFocusRequest &+= 1
|
|
return
|
|
}
|
|
guard findKind != nil else { return }
|
|
isToolbarFindFocused = true
|
|
if findKind == .terminal {
|
|
focusedSSHSession?.find.present()
|
|
focusedTelnetSession?.find.present()
|
|
focusedSerialSession?.find.present()
|
|
}
|
|
findFocusRequest &+= 1
|
|
// On the next turn, so the update the two lines above set off has
|
|
// already landed and cannot take the caret straight back out.
|
|
Task { @MainActor [weak self] in self?.focusToolbarFindField?() }
|
|
}
|
|
|
|
/// ⌘G and F3.
|
|
///
|
|
/// A terminal search starts at the bottom of the buffer, so Ghostty's
|
|
/// `.previous` is the direction that reaches the first match — which is what
|
|
/// a reader means by "next". Return in the Find field is bound the same way;
|
|
/// the two used to disagree.
|
|
func navigateTerminalFind(_ direction: TerminalFindDirection) {
|
|
focusedSSHSession?.find.presentAndNavigate(direction)
|
|
focusedTelnetSession?.find.presentAndNavigate(direction)
|
|
focusedSerialSession?.find.presentAndNavigate(direction)
|
|
}
|
|
|
|
/// The workspace whose content the detail column shows.
|
|
///
|
|
/// The selected one, except while the selected one is the thing being
|
|
/// dragged. Merging is done by dropping a workspace into a Workspace's pane area,
|
|
/// and a workspace cannot be dropped into itself — so leaving it on screen would
|
|
/// point the app's one merge gesture at the only target that refuses it.
|
|
/// It steps aside to the next workspace, or the previous one when there is no
|
|
/// next: strictly adjacent, and only when that neighbour is somewhere a workspace
|
|
/// could actually land.
|
|
///
|
|
/// Derived rather than a real selection change, the same way
|
|
/// `WorkspaceContentView.displayedLayout` draws a Workspace without the pane in
|
|
/// flight. Nothing is committed, so every way a drag can end — a reorder, a
|
|
/// merge, a cancel, a release outside the window that no delegate ever hears
|
|
/// about — restores the selected view once the source clears its drag state.
|
|
var displayedWorkspace: Workspace? {
|
|
guard case .workspace(let id)? = dragging,
|
|
id == selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == id }),
|
|
workspaces[index].isCombinable
|
|
else { return selectedWorkspace }
|
|
for neighbour in [index + 1, index - 1] where workspaces.indices.contains(neighbour) {
|
|
if workspaces[neighbour].acceptsMerge { return workspaces[neighbour] }
|
|
}
|
|
return selectedWorkspace
|
|
}
|
|
|
|
/// Every pane of a given kind, with the Workspace holding it.
|
|
///
|
|
/// The sidebar lists *panes*, not workspaces. A Workspace can be mixed — a shell
|
|
/// beside a file browser — and it belongs under both Sessions and File
|
|
/// Transfers rather than under whichever half had focus last. Filing the
|
|
/// whole workspace by its focused pane meant a row that changed sections as you
|
|
/// clicked around inside it, which is the one way a list of what is open can
|
|
/// be worse than no list.
|
|
///
|
|
/// It also means a Workspace with three shells gets three rows, which is the
|
|
/// point of naming panes — see `Pane.customTitle`.
|
|
func panes(ofKind kind: PaneKind) -> [PaneRef] {
|
|
workspaces.flatMap { workspace in
|
|
workspace.panes
|
|
.filter { $0.kind == kind }
|
|
.map(PaneRef.init(pane:))
|
|
}
|
|
}
|
|
|
|
/// Shells on a host — what the sidebar lists under "Sessions", and what
|
|
/// "Close All Sessions" acts on.
|
|
///
|
|
/// Deliberately narrower than "not a page": a host editor is a form, a chat
|
|
/// is a conversation, and a host's tunnels are a setting on that host. None
|
|
/// of them is an SSH session no matter how it got opened.
|
|
var sshSessionPanes: [PaneRef] { panes(ofKind: .sshSession) }
|
|
|
|
/// One host's file browser, per open browser. The queue page is not among
|
|
/// them: it is a page, so its `kind` is `.page`, and it lives in Manage.
|
|
var sftpChannelPanes: [PaneRef] { panes(ofKind: .sftpChannel) }
|
|
|
|
var serialSessionPanes: [PaneRef] { panes(ofKind: .serialSession) }
|
|
|
|
var sessionPanes: [PaneRef] {
|
|
workspaces.flatMap { workspace in
|
|
workspace.panes.compactMap { pane in
|
|
guard
|
|
pane.kind == .sshSession || pane.kind == .sftpChannel
|
|
|| pane.kind == .serialSession
|
|
else { return nil }
|
|
return PaneRef(pane: pane)
|
|
}
|
|
}
|
|
}
|
|
|
|
var canReopen: Bool { !reopenStack.isEmpty }
|
|
|
|
func select(_ id: Workspace.ID) {
|
|
guard workspaces.contains(where: { $0.id == id }) else { return }
|
|
selectedDestination = .workspace(id)
|
|
workspaces.first { $0.id == id }?.markRead()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func selectVault() {
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
/// Enters the Assistant without changing its in-memory navigation state:
|
|
/// the current conversation, New Chat, and Archive are all left untouched.
|
|
func selectAssistant() {
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func selectAssistant(_ id: ConversationModel.ID) {
|
|
guard openAssistantConversation(id) else { return }
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func openSettings(route: SettingsRoute = .index) {
|
|
if selectedDestination != .settings {
|
|
settingsReturnDestination = selectedDestination
|
|
}
|
|
settingsRoute = route
|
|
selectedDestination = .settings
|
|
}
|
|
|
|
func closeSettings() {
|
|
let destination = settingsReturnDestination
|
|
settingsRoute = .index
|
|
switch destination {
|
|
case .workspace(let id):
|
|
if workspaces.contains(where: { $0.id == id }) {
|
|
select(id)
|
|
} else {
|
|
selectVault()
|
|
}
|
|
case .vault:
|
|
selectVault()
|
|
case .assistant:
|
|
selectAssistant()
|
|
case .metrics:
|
|
selectedDestination = .metrics
|
|
case .settings:
|
|
selectVault()
|
|
}
|
|
}
|
|
|
|
func navigateBackInSettings() {
|
|
guard selectedDestination == .settings else { return }
|
|
if let parent = settingsRoute.parent {
|
|
settingsRoute = parent
|
|
} else {
|
|
closeSettings()
|
|
}
|
|
}
|
|
|
|
/// Promotes a compact Sidebar preview into the main Assistant surface while
|
|
/// keeping the conversation navigator visible in the shared Sidebar.
|
|
func openAssistantFullScreen(_ id: ConversationModel.ID? = nil) {
|
|
sidebarMode = .assistant
|
|
if let id {
|
|
selectAssistant(id)
|
|
} else {
|
|
_ = openNewAssistantChat()
|
|
}
|
|
}
|
|
|
|
func showAssistantArchive() {
|
|
presentAssistantArchive()
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func isShowingAssistant(_ id: UUID) -> Bool {
|
|
guard !isClosed, !isAssistantArchivePresented, assistantLibrary.selectedConversationID == id else {
|
|
return false
|
|
}
|
|
if selectedDestination == .assistant { return true }
|
|
return selectedWorkspace != nil && showsSidebar && sidebarMode == .assistant
|
|
}
|
|
|
|
var visibleAssistantConversationID: UUID? {
|
|
guard let id = assistantLibrary.selectedConversationID, isShowingAssistant(id) else { return nil }
|
|
return id
|
|
}
|
|
|
|
var sidebarAssistantConversation: ConversationModel? {
|
|
guard !isAssistantArchivePresented else { return nil }
|
|
return assistantLibrary.selectedConversation
|
|
}
|
|
|
|
func openAssistantInSidebar(_ id: ConversationModel.ID) {
|
|
guard openAssistantConversation(id) else { return }
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func closeAssistantInSidebar() {
|
|
presentAssistantHome()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func showAssistantArchiveInSidebar() {
|
|
presentAssistantArchive()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
@discardableResult
|
|
private func openAssistantConversation(_ id: ConversationModel.ID) -> Bool {
|
|
guard assistantLibrary.conversation(id: id) != nil else { return false }
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.select(id)
|
|
return true
|
|
}
|
|
|
|
private func presentAssistantHome() {
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.selectNewConversation()
|
|
}
|
|
|
|
func presentAssistantArchive() {
|
|
assistantLibrary.selectNewConversation()
|
|
isAssistantArchivePresented = true
|
|
}
|
|
|
|
func deleteAssistantConversation(_ id: ConversationModel.ID) {
|
|
if let assistantCommands {
|
|
assistantCommands.delete(id)
|
|
return
|
|
}
|
|
transcriptCache.removeConversation(id)
|
|
let wasArchived = assistantLibrary.conversation(id: id)?.isArchived == true
|
|
let wasSelected = assistantLibrary.selectedConversationID == id
|
|
assistantLibrary.delete(id)
|
|
if wasArchived, wasSelected { presentAssistantArchive() }
|
|
}
|
|
|
|
func toggleAssistantPin(_ id: ConversationModel.ID) {
|
|
if let assistantCommands {
|
|
assistantCommands.togglePin(id)
|
|
} else if let conversation = assistantLibrary.conversation(id: id),
|
|
!conversation.isArchived
|
|
{
|
|
let toPinned = !conversation.isPinned
|
|
let nextID =
|
|
(toPinned
|
|
? assistantLibrary.pinnedConversations
|
|
: assistantLibrary.activeConversations).first?.id
|
|
moveAssistantConversation(id, toPinned: toPinned, before: nextID)
|
|
}
|
|
}
|
|
func moveAssistantConversation(
|
|
_ id: ConversationModel.ID,
|
|
toPinned: Bool,
|
|
before nextID: ConversationModel.ID?
|
|
) {
|
|
if let assistantCommands {
|
|
assistantCommands.move(id, toPinned, nextID)
|
|
} else if let updates = assistantLibrary.orderUpdates(
|
|
moving: id, toPinned: toPinned, before: nextID
|
|
) {
|
|
assistantLibrary.applyOrder(updates)
|
|
}
|
|
}
|
|
func renameAssistantConversation(_ id: ConversationModel.ID, to title: String) {
|
|
if let assistantCommands {
|
|
assistantCommands.rename(id, title)
|
|
} else {
|
|
let value = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty {
|
|
assistantLibrary.conversation(id: id)?.customTitle = String(value.prefix(80))
|
|
}
|
|
}
|
|
}
|
|
func setAssistantArchived(_ id: ConversationModel.ID, archived: Bool) {
|
|
if let assistantCommands {
|
|
assistantCommands.archive(id, archived)
|
|
} else if let conversation = assistantLibrary.conversation(id: id),
|
|
!conversation.isStreaming, !conversation.isCompacting
|
|
{
|
|
conversation.archivedAt = archived ? .now : nil
|
|
if archived { conversation.isPinned = false }
|
|
if archived { assistantConversationWasArchived(id) }
|
|
}
|
|
}
|
|
|
|
/// An archived conversation leaves every live-chat surface that was showing
|
|
/// it. Keeping its id selected would strand the window on a read-only chat
|
|
/// that has just disappeared from the ordinary navigator.
|
|
func assistantConversationWasArchived(_ id: ConversationModel.ID) {
|
|
if assistantLibrary.selectedConversationID == id {
|
|
assistantLibrary.selectNewConversation()
|
|
isAssistantArchivePresented = false
|
|
}
|
|
}
|
|
func forkAssistantConversation(
|
|
_ id: ConversationModel.ID, at messageID: UUID, inSidebar: Bool = false
|
|
) {
|
|
assistantCommands?.fork(id, messageID, inSidebar)
|
|
}
|
|
func editAssistantUserMessage(
|
|
_ id: ConversationModel.ID, messageID: UUID, text: String,
|
|
context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false
|
|
) {
|
|
assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable)
|
|
}
|
|
|
|
@discardableResult
|
|
func sendAssistantSidebarDraft() -> ConversationModel? {
|
|
let draft = sidebar.assistantDraft
|
|
guard let conversation = assistantLibrary.startConversation(draft) else { return nil }
|
|
isAssistantArchivePresented = false
|
|
sidebar.assistantDraft = ConversationModel()
|
|
return conversation
|
|
}
|
|
|
|
func assistantSidebarConversationDidStart(_ conversation: ConversationModel) {
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.select(conversation.id)
|
|
sidebar.assistantDraft = ConversationModel()
|
|
}
|
|
|
|
/// Republishes the focused Workspace's host, directory, and OS facts for the
|
|
/// sidebar composer.
|
|
///
|
|
/// It writes to the column, not to a conversation. Stamping this onto
|
|
/// whichever chat the sidebar happened to be showing meant the chat kept it:
|
|
/// send from the sidebar, open the same conversation in the Assistant workspace,
|
|
/// and a host and directory that workspace knows nothing about were still on
|
|
/// display — and would still have been uploaded.
|
|
///
|
|
/// No Workspace selected clears it rather than returning early. Leaving the
|
|
/// last value in place froze a host and directory the user had navigated
|
|
/// away from.
|
|
func refreshSessionContext() {
|
|
guard let workspace = selectedWorkspace, let hostStore else {
|
|
sidebar.sessionContext = .none
|
|
return
|
|
}
|
|
sidebar.sessionContext = SessionContext(
|
|
hostID: workspace.focusedPane.hostID,
|
|
hostName: workspace.focusedPane.hostID.flatMap { hostStore.host($0)?.name },
|
|
workingDirectory: workspace.focusedPane.workingDirectory,
|
|
workingDirectoryDisplayLabel: workspace.focusedPane.displayedWorkingDirectory,
|
|
showsWorkingDirectoryChip: workspace.focusedPane.displayedWorkingDirectory != nil,
|
|
systemInfo: workspace.focusedPane.remoteSystemInfo
|
|
)
|
|
}
|
|
|
|
/// Selects a dynamic Workspace by its order in the Navigator.
|
|
func select(index: Int) {
|
|
guard workspaces.indices.contains(index) else { return }
|
|
select(workspaces[index].id)
|
|
}
|
|
|
|
func selectNext() {
|
|
guard !workspaces.isEmpty else { return }
|
|
guard let selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == selectedWorkspaceID })
|
|
else {
|
|
select(workspaces[0].id)
|
|
return
|
|
}
|
|
select(workspaces[(index + 1) % workspaces.count].id)
|
|
}
|
|
|
|
func selectPrevious() {
|
|
guard !workspaces.isEmpty else { return }
|
|
guard let selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == selectedWorkspaceID })
|
|
else {
|
|
select(workspaces[workspaces.count - 1].id)
|
|
return
|
|
}
|
|
select(workspaces[(index - 1 + workspaces.count) % workspaces.count].id)
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
@discardableResult
|
|
func open(
|
|
_ workspace: Workspace,
|
|
select shouldSelect: Bool = true
|
|
) -> Workspace {
|
|
workspace.panes.forEach(configureConnectionHistory)
|
|
workspace.lifecycleRouter = self
|
|
if let connectionCoordinator {
|
|
workspace.configureConnections(using: connectionCoordinator)
|
|
}
|
|
if let commandHistoryStore {
|
|
workspace.configureCommandHistory(using: commandHistoryStore)
|
|
}
|
|
if let sftpHistoryStore {
|
|
workspace.configureSFTPHistory(using: sftpHistoryStore)
|
|
}
|
|
workspaces.append(workspace)
|
|
if shouldSelect { select(workspace.id) }
|
|
return workspace
|
|
}
|
|
|
|
/// Opens a route inside the pinned Vault workspace.
|
|
func openPage(_ page: VaultPage) {
|
|
vaultDestination = VaultDestination(page: page)
|
|
vaultWorkRoute = nil
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
func openVaultWork(_ route: VaultWorkRoute, on page: VaultPage) {
|
|
vaultDestination = VaultDestination(page: page)
|
|
vaultWorkRoute = route
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
func closeVaultWork() {
|
|
vaultWorkRoute = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func openSSHSession(
|
|
host: Host,
|
|
usernameHint: String? = nil,
|
|
connectionRoute: SSHConnectionRoute? = nil,
|
|
initialPath: String? = nil,
|
|
placement: SessionOpenPlacement = .newWorkspace
|
|
) -> Workspace {
|
|
let pane = Pane.sshSession(
|
|
host: host,
|
|
usernameHint: usernameHint,
|
|
initialCommand: initialPath.flatMap(RemoteShellCommand.changeDirectory),
|
|
recordsInitialCommandInHistory: initialPath == nil
|
|
)
|
|
pane.sshSession?.connectionRoute = connectionRoute
|
|
configureConnectionHistory(pane)
|
|
|
|
if placement == .currentWorkspace,
|
|
let workspace = selectedWorkspace,
|
|
workspace.focusedPane.sftpChannel != nil
|
|
{
|
|
workspace.splitFocusedPane(axis: .horizontal, with: pane)
|
|
return workspace
|
|
}
|
|
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openTelnetSession(host: Host) -> Workspace {
|
|
let pane = Pane.telnetSession(host: host)
|
|
configureConnectionHistory(pane)
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openExternalTelnetSession(
|
|
host: Host, username: String?, password: SSHSecret?
|
|
) -> Workspace {
|
|
let pane = Pane.telnetSession(
|
|
host: host, usernameHint: username,
|
|
advisoryPassword: password,
|
|
isExternalTemporaryConnection: true)
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
func disconnectSessions(
|
|
for hostID: Host.ID, protocol connectionProtocol: HostTerminalProtocol, message: String
|
|
) {
|
|
for pane in workspaces.flatMap(\.panes) where pane.hostID == hostID {
|
|
switch (connectionProtocol, pane.content) {
|
|
case (.ssh, .sshSession(let session)):
|
|
session.disconnect()
|
|
session.status = .failed(message)
|
|
case (.ssh, .sftpChannel(let channel)):
|
|
channel.disconnect()
|
|
case (.telnet, .telnetSession(let session)):
|
|
session.disconnect()
|
|
session.status = .failed(message)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Batch Startup opens every host as a pane of one new Workspace, tiled into a grid.
|
|
///
|
|
/// Returns nil for a target that cannot be a Workspace pane — only SSH and
|
|
/// SFTP channels can, which is the same rule `PaneKind.isCombinable`
|
|
/// enforces everywhere else.
|
|
@discardableResult
|
|
func openWorkspace(_ hosts: [Host], as target: HostOpenTarget) -> Workspace? {
|
|
let panes: [Pane] = hosts.compactMap { host in
|
|
switch target {
|
|
case .preferredTerminal:
|
|
switch host.preferredTerminalProtocol {
|
|
case .ssh: .sshSession(host: host)
|
|
case .telnet: .telnetSession(host: host)
|
|
case nil: nil
|
|
}
|
|
case .sshSession: .sshSession(host: host)
|
|
case .telnetSession: .telnetSession(host: host)
|
|
case .sftpChannel: .sftpChannel(host: host)
|
|
}
|
|
}
|
|
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
|
|
|
let workspace = Workspace(pane: first)
|
|
workspace.layout = layout
|
|
workspace.focusedPaneID = first.id
|
|
open(workspace)
|
|
return workspace
|
|
}
|
|
|
|
/// Opens every target of one Snippet as visible SSH panes. SSH session and
|
|
/// Workspace titles keep their ordinary host-derived defaults; each pane
|
|
/// submits the same command independently after login.
|
|
@discardableResult
|
|
func openSnippetWorkspace(hosts: [Host], command: String) -> Workspace? {
|
|
let panes = hosts.map { Pane.sshSession(host: $0, initialCommand: command) }
|
|
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
|
|
|
let workspace = Workspace(pane: first)
|
|
workspace.layout = layout
|
|
workspace.focusedPaneID = first.id
|
|
open(workspace)
|
|
return workspace
|
|
}
|
|
|
|
@discardableResult
|
|
func openNewAssistantChat() -> ConversationModel {
|
|
presentAssistantHome()
|
|
selectedDestination = .assistant
|
|
return assistantLibrary.draftConversation
|
|
}
|
|
|
|
/// One machine's SFTP browser, listed in the sidebar under "Sessions".
|
|
///
|
|
/// A caller with a terminal working directory can override the host's saved
|
|
/// initial path for this browser only. Opening in the current Workspace is
|
|
/// an explicit layout choice and always places the browser to the right of
|
|
/// the focused shell.
|
|
@discardableResult
|
|
func openSFTPChannel(
|
|
host: Host,
|
|
initialPath: String? = nil,
|
|
placement: SessionOpenPlacement = .newWorkspace
|
|
) -> Workspace {
|
|
let pane = Pane.sftpChannel(host: host, initialPath: initialPath)
|
|
configureConnectionHistory(pane)
|
|
if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) }
|
|
if let sftpHistoryStore { pane.configureSFTPHistory(using: sftpHistoryStore) }
|
|
|
|
if placement == .currentWorkspace,
|
|
let workspace = selectedWorkspace,
|
|
workspace.focusedPane.sshSession != nil
|
|
{
|
|
workspace.splitFocusedPane(axis: .horizontal, with: pane)
|
|
return workspace
|
|
}
|
|
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openSerialSession(
|
|
configuration: SerialConnectionConfiguration,
|
|
service: any SerialPortServing
|
|
) -> Workspace {
|
|
open(Workspace(pane: .serialSession(configuration: configuration, service: service)))
|
|
}
|
|
|
|
private func configureConnectionHistory(_ pane: Pane) {
|
|
configureCommandNotifications?(pane)
|
|
guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return }
|
|
let kind: ConnectionHistoryEntry.Kind
|
|
switch pane.kind {
|
|
case .sshSession: kind = .ssh
|
|
case .telnetSession: kind = .telnet
|
|
case .sftpChannel: kind = .sftp
|
|
case .serialSession, .empty: return
|
|
}
|
|
guard let entryID = recordConnectionHistory?(hostID, kind), let endConnectionHistory else { return }
|
|
pane.configureConnectionHistory(entryID: entryID, end: endConnectionHistory)
|
|
}
|
|
|
|
/// Opens the host editor, reusing the workspace that already holds one for the
|
|
/// same host — and the same initial group counts as the same new Host form.
|
|
/// The same reasoning as `openPage`: a half-typed form is easier to find
|
|
/// again than to tell apart from another empty copy.
|
|
func openHostEditor(host: Host?, groupID: HostGroup.ID? = nil) {
|
|
openVaultWork(
|
|
.hostEditor(host?.id, groupID: host == nil ? groupID : nil),
|
|
on: .hosts
|
|
)
|
|
}
|
|
|
|
// MARK: - Combining
|
|
|
|
/// Folds every pane of `source` into `target` beside a named pane, on a named
|
|
/// side, and closes the source workspace.
|
|
///
|
|
/// The one merge gesture in the app: drag a workspace out of the strip and drop it
|
|
/// on the edge of a pane. Which edge you let go over is which side the panes
|
|
/// land on, so the preview rectangle and the result are the same shape. The
|
|
/// workspace strip itself merges nothing — a workspace dropped there reorders, and a pane
|
|
/// dropped there becomes its own workspace.
|
|
///
|
|
/// A Workspace is built only from things already open. There used to be a
|
|
/// "Connect in Split" that welded a host you had never opened onto whatever
|
|
/// workspace happened to be front; combining is a rearrangement of what is on
|
|
/// screen, and nothing connects as a side effect of a layout change.
|
|
///
|
|
/// The source's own thread goes with it. A Workspace has one thread of its
|
|
/// own, and silently keeping the wrong one is worse than continuing the
|
|
/// survivor's — which is the one still in front of you. A *chat pane's*
|
|
/// thread is not the workspace's and travels with the pane, so folding a chat into
|
|
/// a Workspace brings its conversation along intact.
|
|
func merge(
|
|
_ source: Workspace.ID,
|
|
into target: Workspace.ID,
|
|
beside paneID: Pane.ID,
|
|
axis: SplitAxis,
|
|
side: SplitSide
|
|
) {
|
|
guard source != target,
|
|
let sourceWorkspace = workspaces.first(where: { $0.id == source }),
|
|
let targetWorkspace = workspaces.first(where: { $0.id == target }),
|
|
sourceWorkspace.isCombinable,
|
|
let targetPane = targetWorkspace.layout.pane(paneID),
|
|
targetPane.isCombinable
|
|
else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.splitting(
|
|
paneID,
|
|
axis: axis,
|
|
side: side,
|
|
with: sourceWorkspace.layout
|
|
)
|
|
targetWorkspace.focusedPaneID = sourceWorkspace.layout.firstPane.id
|
|
workspaces.removeAll { $0.id == source }
|
|
select(target)
|
|
}
|
|
|
|
/// The same fold, into a specific empty slot rather than beside the focused
|
|
/// pane — what dropping onto an `EmptyPaneView` does.
|
|
func fill(_ slot: Pane.ID, in target: Workspace.ID, withPanesOf source: Workspace.ID) {
|
|
guard source != target,
|
|
let sourceWorkspace = workspaces.first(where: { $0.id == source }),
|
|
let targetWorkspace = workspaces.first(where: { $0.id == target }),
|
|
sourceWorkspace.isCombinable,
|
|
targetWorkspace.layout.pane(slot)?.isEmptySlot == true
|
|
else { return }
|
|
targetWorkspace.replacePane(slot, with: sourceWorkspace.layout)
|
|
workspaces.removeAll { $0.id == source }
|
|
select(target)
|
|
}
|
|
|
|
// MARK: - Rearranging panes
|
|
|
|
/// Moves one pane beside another, inside the Workspace they share.
|
|
///
|
|
/// Panes do not cross Workspaces. Getting a pane into a different one is two
|
|
/// deliberate moves — drop it on the workspace strip so it becomes a workspace, then drag
|
|
/// that workspace into the target's pane area — rather than one gesture a few
|
|
/// pixels from a gesture that means something else. `PaneLayoutDropController`
|
|
/// enforces the same-Workspace rule at the other end.
|
|
///
|
|
/// Lifted out before it is placed. That is safe because the target is never
|
|
/// the pane being moved (the caller's own guard), so it always survives the
|
|
/// removal — and doing it in this order means the pane is never in the tree
|
|
/// twice, which matters because both copies would share one id and `removing`
|
|
/// would take out the wrong one.
|
|
///
|
|
/// Closes the source workspace when the move emptied it.
|
|
func movePane(_ paneID: Pane.ID, beside target: Pane.ID, axis: SplitAxis, side: SplitSide) {
|
|
guard paneID != target, let pane = pane(paneID) else { return }
|
|
detach(paneID)
|
|
guard let targetWorkspace = workspace(holding: target) else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.splitting(
|
|
target, axis: axis, side: side, with: pane
|
|
)
|
|
targetWorkspace.focusedPaneID = paneID
|
|
select(targetWorkspace.id)
|
|
}
|
|
|
|
/// The same move, into an unfilled slot, which the pane replaces.
|
|
func movePane(_ paneID: Pane.ID, into slot: Pane.ID) {
|
|
guard paneID != slot, let pane = pane(paneID) else { return }
|
|
detach(paneID)
|
|
guard let targetWorkspace = workspace(holding: slot) else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.replacing(slot, with: .leaf(pane))
|
|
targetWorkspace.focusedPaneID = paneID
|
|
select(targetWorkspace.id)
|
|
}
|
|
|
|
/// Lifts a pane out into a workspace of its own at `index`, or at the end of the
|
|
/// strip when there is none.
|
|
///
|
|
/// What dropping a pane on the workspace strip does — anywhere on it. The strip
|
|
/// takes a pane to mean one thing only, so there is no zone to aim for and no
|
|
/// second outcome a few pixels away.
|
|
///
|
|
/// The inverse of `merge`: a Workspace can be taken apart the way it was put
|
|
/// together. The new workspace gets its own conversation — the thread stays with
|
|
/// the Workspace that held it, the same choice `duplicateSelectedInNewWorkspace`
|
|
/// makes, because carrying it along would hand one thread's context to two
|
|
/// workspaces that no longer share a screen.
|
|
///
|
|
/// A no-op for a pane that is already alone in its workspace: there is nothing to
|
|
/// detach it from, and doing it anyway would be a reorder wearing the wrong
|
|
/// name. That also means the lift never removes a workspace, so an index worked out
|
|
/// before the call is still the right one after it.
|
|
@discardableResult
|
|
func detachPane(_ paneID: Pane.ID, at index: Int? = nil) -> Workspace? {
|
|
guard let pane = pane(paneID),
|
|
let source = workspace(holding: paneID),
|
|
source.panes.count > 1
|
|
else { return nil }
|
|
detach(paneID)
|
|
|
|
let workspace = automaticallyNamedWorkspace(pane: pane)
|
|
workspace.lifecycleRouter = self
|
|
workspaces.insert(workspace, at: min(index ?? workspaces.count, workspaces.count))
|
|
select(workspace.id)
|
|
return workspace
|
|
}
|
|
|
|
/// The inverse of `merge`: every pane of a split Workspace becomes a
|
|
/// Workspace of its own.
|
|
///
|
|
/// A Workspace is built by folding things together, so it has to be possible
|
|
/// to take one back apart in a single move — otherwise undoing a merge means
|
|
/// dragging each pane out to the Navigator one at a time, and the last one
|
|
/// cannot be dragged out at all.
|
|
///
|
|
/// The panes keep their layout order, and the first stays in the original
|
|
/// Workspace so the row you right-clicked is still there afterwards. The
|
|
/// sessions are untouched: this is a rearrangement, and nothing disconnects.
|
|
func separatePanes(in id: Workspace.ID) {
|
|
guard let source = workspaces.first(where: { $0.id == id }), source.isSplit,
|
|
let index = workspaces.firstIndex(where: { $0.id == id })
|
|
else { return }
|
|
|
|
// Front to back, each inserted just after the one before it. Detaching
|
|
// never removes the source — `detachPane` refuses a pane that is already
|
|
// alone — so this index stays correct for the whole run.
|
|
for (offset, pane) in source.panes.dropFirst().enumerated() {
|
|
detachPane(pane.id, at: index + 1 + offset)
|
|
}
|
|
select(id)
|
|
}
|
|
|
|
/// Takes a pane out of whatever holds it, closing that workspace if it was the
|
|
/// last one there.
|
|
private func detach(_ paneID: Pane.ID) {
|
|
guard let workspace = workspace(holding: paneID) else { return }
|
|
if !workspace.closePane(paneID) {
|
|
removeWorkspaceForMove(workspace.id)
|
|
}
|
|
}
|
|
|
|
/// Removes an emptied source workspace while its pane is being moved. This is not
|
|
/// a close: the same live session is about to be inserted elsewhere.
|
|
private func removeWorkspaceForMove(_ id: Workspace.ID) {
|
|
guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
workspaces.remove(at: index)
|
|
if selectedWorkspaceID == id {
|
|
selectedDestination =
|
|
workspaces.indices.contains(index)
|
|
? .workspace(workspaces[index].id)
|
|
: workspaces.last.map { .workspace($0.id) } ?? .vault
|
|
}
|
|
}
|
|
|
|
func pane(_ id: Pane.ID) -> Pane? {
|
|
workspaces.lazy.compactMap { $0.layout.pane(id) }.first
|
|
}
|
|
|
|
func workspace(holding paneID: Pane.ID) -> Workspace? {
|
|
workspaces.first { $0.layout.pane(paneID) != nil }
|
|
}
|
|
|
|
// MARK: - Splitting and duplicating
|
|
|
|
/// Whether Split and Duplicate have anything to act on.
|
|
///
|
|
/// Both come down to the same question — is the focused pane something that
|
|
/// belongs in a Workspace — so both read the same predicate. Carving a slot
|
|
/// beside a manager page, or a second copy of a hole, are results nobody
|
|
/// asked for; better a disabled menu item than a command that quietly does
|
|
/// nothing. See `PaneKind.isCombinable`.
|
|
var canSplitSelected: Bool {
|
|
selectedWorkspace?.focusedPane.isCombinable ?? false
|
|
}
|
|
|
|
var canDuplicateSelected: Bool {
|
|
guard canSplitSelected, let workspace = selectedWorkspace else { return false }
|
|
return !workspace.panes.contains { $0.serialSession != nil }
|
|
}
|
|
|
|
/// Carves an empty slot beside the focused pane, for something already open
|
|
/// to be dragged into. Deliberately not a connection: choosing a layout and
|
|
/// choosing a machine are two decisions, and only one of them was asked for.
|
|
func splitSelected(axis: SplitAxis) {
|
|
guard let workspace = selectedWorkspace, canSplitSelected else { return }
|
|
workspace.splitFocusedPane(axis: axis, with: Pane(.empty))
|
|
}
|
|
|
|
/// Copies the focused pane beside itself, keeping its kind — a second shell
|
|
/// on the same host, a second browser on the same machine.
|
|
func duplicateSelected(axis: SplitAxis) {
|
|
guard let workspace = selectedWorkspace, let copy = copyOfFocusedPane() else { return }
|
|
workspace.splitFocusedPane(axis: axis, with: copy)
|
|
}
|
|
|
|
/// The same copy, in a workspace of its own — for when the second shell is a
|
|
/// separate job rather than something to watch side by side.
|
|
///
|
|
/// Pane-level, and named so, because the sidebar's rows are panes: "Duplicate
|
|
/// in New Workspace" on a row that names one shell has to mean that shell, even
|
|
/// when it lives in a Workspace with two others.
|
|
func duplicatePaneInNewWorkspace() {
|
|
guard let copy = copyOfFocusedPane() else { return }
|
|
open(automaticallyNamedWorkspace(pane: copy))
|
|
}
|
|
|
|
/// Workspace-level: a Workspace is copied whole — same layout, same divider
|
|
/// positions, fresh panes — and anything else copies its one pane.
|
|
///
|
|
/// The scope follows the surface. This is what the *workspace* menu and the menu
|
|
/// bar call, and both of those point at a workspace; a right-click on a Workspace
|
|
/// points at no particular pane, so duplicating whichever one the caret was
|
|
/// last in would be a coin toss dressed as a command. `duplicatePaneInNewWorkspace`
|
|
/// is the pane-level twin, for the sidebar.
|
|
///
|
|
/// The copy is the arrangement and nothing else. Names do not come along —
|
|
/// two workspaces called "prod cluster" is a collision, not a copy, the same
|
|
/// reasoning `Pane.copy` gives — and neither does `broadcastsInput`: a
|
|
/// duplicate that arrived already broadcasting would send the first thing
|
|
/// typed into it to shells nobody switched it on for.
|
|
func duplicateSelectedInNewWorkspace() {
|
|
guard let sourceWorkspace = selectedWorkspace else { return }
|
|
guard sourceWorkspace.isSplit else { return duplicatePaneInNewWorkspace() }
|
|
guard let layout = sourceWorkspace.layout.copied() else { return }
|
|
|
|
let duplicate = Workspace(pane: layout.firstPane)
|
|
duplicate.layout = layout
|
|
duplicate.focusedPaneID = layout.firstPane.id
|
|
open(duplicate)
|
|
}
|
|
|
|
func toggleBroadcastInput(in workspace: Workspace) {
|
|
workspace.broadcastsInput.toggle()
|
|
}
|
|
|
|
/// An unfilled slot is not something to make a second of, so this is
|
|
/// narrower than `Pane.copy` — which does copy one, because a Workspace
|
|
/// duplicated whole should arrive with the same hole in it.
|
|
private func copyOfFocusedPane() -> Pane? {
|
|
guard let source = selectedWorkspace?.focusedPane, source.isCombinable,
|
|
let copy = source.copy()
|
|
else { return nil }
|
|
return copy
|
|
}
|
|
|
|
private func automaticallyNamedWorkspace(pane: Pane, prefix: String? = nil) -> Workspace {
|
|
let workspace = Workspace(pane: pane)
|
|
guard let prefix = prefix ?? automaticNamePrefix(for: pane) else { return workspace }
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
return workspace
|
|
}
|
|
|
|
private func automaticNamePrefix(for pane: Pane) -> String? {
|
|
if let hostID = pane.hostID, let name = hostStore?.host(hostID)?.name { return name }
|
|
switch pane.content {
|
|
case .sshSession(let session): return session.title
|
|
case .telnetSession(let session): return session.title
|
|
case .sftpChannel: return nil
|
|
case .serialSession, .empty: return nil
|
|
}
|
|
}
|
|
|
|
private func occupiedWorkspaceNames(locale: Locale = .current, excluding excludedID: Workspace.ID? = nil)
|
|
-> [String]
|
|
{
|
|
let store = hostStore ?? HostLibraryModel()
|
|
return workspaces.filter { $0.id != excludedID }.map { $0.title(in: store, locale: locale) }
|
|
}
|
|
|
|
/// Moving preserves live transports and does not populate close history.
|
|
func takeWorkspaceForMove(_ id: Workspace.ID) -> Workspace? {
|
|
guard !isClosed, let workspace = workspaces.first(where: { $0.id == id }) else { return nil }
|
|
removeWorkspaceForMove(id)
|
|
workspace.lifecycleRouter = nil
|
|
dragging = nil
|
|
if workspaces.isEmpty {
|
|
selectedDestination = .vault
|
|
vaultDestination = .welcome
|
|
vaultWorkRoute = nil
|
|
}
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
return workspace
|
|
}
|
|
|
|
/// Extracts live pane state only after another window is ready to own it.
|
|
/// A lone pane carries its existing workspace, including its assigned name.
|
|
func takePaneForMove(_ id: Pane.ID) -> Workspace? {
|
|
guard !isClosed, let pane = pane(id), pane.isCombinable,
|
|
let source = workspace(holding: id)
|
|
else { return nil }
|
|
if source.panes.count == 1 { return takeWorkspaceForMove(source.id) }
|
|
guard source.closePane(id) else { return nil }
|
|
let workspace = automaticallyNamedWorkspace(pane: pane)
|
|
dragging = nil
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
return workspace
|
|
}
|
|
|
|
func closeForWindow() {
|
|
guard !isClosed else { return }
|
|
isClosed = true
|
|
for workspace in workspaces {
|
|
workspace.disconnectConnections()
|
|
workspace.lifecycleRouter = nil
|
|
}
|
|
workspaces.removeAll()
|
|
reopenStack.removeAll()
|
|
dragging = nil
|
|
presentations.route = nil
|
|
transcriptCache.removeAll()
|
|
focusToolbarFindField = nil
|
|
selectedDestination = .vault
|
|
refreshSessionContext()
|
|
}
|
|
|
|
// MARK: - Closing
|
|
|
|
func close(_ id: Workspace.ID) {
|
|
guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
let removed = workspaces.remove(at: index)
|
|
removed.disconnectConnections()
|
|
reopenStack.append(removed)
|
|
if reopenStack.count > 10 { reopenStack.removeFirst() }
|
|
|
|
if selectedWorkspaceID == id {
|
|
// Select the neighbour that visually takes its place.
|
|
if workspaces.indices.contains(index) {
|
|
selectedDestination = .workspace(workspaces[index].id)
|
|
} else if let neighbour = workspaces.last {
|
|
selectedDestination = .workspace(neighbour.id)
|
|
} else {
|
|
promoteVisibleSidebarConversationOrSelectVault()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ⌘W: closes the focused *pane* when the workspace is split, otherwise the workspace.
|
|
/// This matches how every split-capable terminal behaves, and is why
|
|
/// `closePane` reports whether the workspace survived.
|
|
func closeSelected() {
|
|
guard let workspace = selectedWorkspace else { return }
|
|
if workspace.isSplit {
|
|
workspace.focusedPane.disconnectConnection()
|
|
if !workspace.closePane(workspace.focusedPaneID) {
|
|
close(workspace.id)
|
|
}
|
|
} else {
|
|
close(workspace.id)
|
|
}
|
|
}
|
|
|
|
func closeOthers(than id: Workspace.ID) {
|
|
for workspace in workspaces where workspace.id != id {
|
|
workspace.disconnectConnections()
|
|
reopenStack.append(workspace)
|
|
}
|
|
workspaces.removeAll { $0.id != id }
|
|
selectedWorkspaceID = id
|
|
}
|
|
|
|
func closeAll() {
|
|
for workspace in workspaces { workspace.disconnectConnections() }
|
|
reopenStack.append(contentsOf: workspaces)
|
|
workspaces.removeAll()
|
|
promoteVisibleSidebarConversationOrSelectVault()
|
|
}
|
|
|
|
private func promoteVisibleSidebarConversationOrSelectVault() {
|
|
guard sidebarMode == .assistant else {
|
|
selectedDestination = .vault
|
|
return
|
|
}
|
|
if isAssistantArchivePresented {
|
|
selectedDestination = .assistant
|
|
} else if let conversationID = assistantLibrary.selectedConversationID,
|
|
assistantLibrary.conversation(id: conversationID) != nil
|
|
{
|
|
openAssistantFullScreen(conversationID)
|
|
} else {
|
|
selectedDestination = .vault
|
|
}
|
|
}
|
|
|
|
/// Closes one pane, and the Workspace with it when that was the last one.
|
|
///
|
|
/// The sidebar's rows are panes, so its Close acts on a pane — closing the
|
|
/// whole Workspace because you dismissed one of its three shells would take
|
|
/// two things you did not point at.
|
|
func closePane(_ paneID: Pane.ID) {
|
|
guard let workspace = workspaces.first(where: { $0.layout.pane(paneID) != nil }) else { return }
|
|
workspace.layout.pane(paneID)?.disconnectConnection()
|
|
if !workspace.closePane(paneID) {
|
|
close(workspace.id)
|
|
}
|
|
}
|
|
|
|
func closeWorkspaces(for hostID: Host.ID) {
|
|
let paneIDs = workspaces.flatMap(\.panes).filter { $0.hostID == hostID }.map(\.id)
|
|
for paneID in paneIDs { closePane(paneID) }
|
|
if case .hostEditor(let editingID, _) = vaultWorkRoute,
|
|
editingID == hostID
|
|
{
|
|
closeVaultWork()
|
|
}
|
|
}
|
|
|
|
func reopenLastClosed() {
|
|
guard let workspace = reopenStack.popLast() else { return }
|
|
workspace.prepareForReopen()
|
|
open(workspace)
|
|
}
|
|
|
|
// MARK: - Reordering
|
|
|
|
/// Drag-reorder in the Navigator: puts `id` at `index` in the Workspace
|
|
/// list after excluding the source workspace from insertion-index geometry.
|
|
///
|
|
/// Remove-then-insert rather than `move(fromOffsets:toOffset:)`. The index
|
|
/// comes from the other visible rows; the offset form would read it as an index into
|
|
/// the list that still contains it, and land one place short of the preview
|
|
/// every time the workspace moved rightwards.
|
|
func move(_ id: Workspace.ID, to index: Int) {
|
|
guard let from = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
let workspace = workspaces.remove(at: from)
|
|
workspaces.insert(workspace, at: min(max(index, 0), workspaces.count))
|
|
}
|
|
|
|
/// A manual name for a single-pane Workspace lives on its pane, while its
|
|
/// generated name remains on the Workspace. A split Workspace names the group.
|
|
///
|
|
/// Manager pages, forms and a host's tunnels refuse outright — see
|
|
/// `Workspace.isRenameable`. The guard is here as well as in the menu so the
|
|
/// rule is a property of the model rather than of one view remembering to
|
|
/// ask.
|
|
func rename(_ id: Workspace.ID, to title: String) {
|
|
guard let workspace = workspaces.first(where: { $0.id == id }), workspace.isRenameable else { return }
|
|
let cleaned = Self.cleaned(title)
|
|
if workspace.isSplit {
|
|
if let cleaned {
|
|
workspace.customTitle = cleaned
|
|
} else {
|
|
let prefix = AppLocalization.resolve(AppStrings.Workspace.workspace, locale: .current)
|
|
let automatic = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
workspace.customTitle = automatic == prefix ? nil : automatic
|
|
}
|
|
} else {
|
|
workspace.focusedPane.customTitle = cleaned
|
|
if cleaned == nil, let prefix = automaticNamePrefix(for: workspace.focusedPane) {
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Panes are renamed from their sidebar row. Empty clears the name rather
|
|
/// than setting a blank one, so there is one way back to the derived title
|
|
/// and it is the obvious one.
|
|
func rename(pane id: Pane.ID, to title: String) {
|
|
guard let workspace = workspaces.first(where: { $0.layout.pane(id) != nil }),
|
|
let pane = workspace.layout.pane(id)
|
|
else { return }
|
|
let cleaned = Self.cleaned(title)
|
|
pane.customTitle = cleaned
|
|
if cleaned == nil, !workspace.isSplit, let prefix = automaticNamePrefix(for: pane) {
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
}
|
|
}
|
|
|
|
private static func cleaned(_ title: String) -> String? {
|
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
extension WindowModel: WorkspaceLifecycleRouting {
|
|
func workspace(_ workspace: Workspace, closePaneAfterSSHSessionExit paneID: Pane.ID) {
|
|
guard workspaces.contains(where: { $0 === workspace }),
|
|
workspace.layout.pane(paneID) != nil
|
|
else { return }
|
|
closePane(paneID)
|
|
}
|
|
|
|
func workspaceContextDidChange(_ workspace: Workspace) {
|
|
commandFocusChanged?()
|
|
guard selectedWorkspace === workspace else { return }
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
}
|
|
|
|
func workspaceRemoteWindowTitleDidChange(_ workspace: Workspace) {
|
|
guard selectedWorkspace === workspace else { return }
|
|
publishRemoteWindowTitle()
|
|
}
|
|
|
|
private func publishRemoteWindowTitle() {
|
|
remoteWindowTitleSink?.windowRemoteTitleDidChange(
|
|
focusedSSHSession?.menuBarWindowTitle
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Dragging
|
|
|
|
/// What a drag in the workspace bar or the pane area is carrying.
|
|
///
|
|
/// Two cases because the same drop target answers both: dropping a *workspace* on a
|
|
/// pane folds that whole Workspace in, while dropping a *pane* moves just that
|
|
/// one out of wherever it was.
|
|
nonisolated enum DragPayload: Hashable, Sendable {
|
|
case workspace(UUID)
|
|
case pane(UUID)
|
|
case assistantConversation(UUID)
|
|
|
|
nonisolated var isLayoutPayload: Bool {
|
|
switch self {
|
|
case .workspace, .pane: true
|
|
case .assistantConversation: false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sidebar rows
|
|
|
|
/// One pane, with the Workspace holding it — what a sidebar row is.
|
|
///
|
|
/// A pair rather than a back-pointer on `Pane`: a pane does not otherwise need
|
|
/// to know which workspace owns it, and a reference cycle for the sake of the sidebar
|
|
/// would be paying in the model for something the view can compute.
|
|
struct PaneRef: Identifiable {
|
|
let pane: Pane
|
|
|
|
var id: Pane.ID { pane.id }
|
|
|
|
}
|