// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import struct TermindDomain.Host protocol WorkspaceLifecycleRouting: AnyObject { func workspace(_ workspace: Workspace, closePaneAfterSSHSessionExit paneID: Pane.ID) func workspaceContextDidChange(_ workspace: Workspace) } /// One workspace in the workspace bar: a tree of panes. /// /// Called a *Workspace* once it holds more than one pane, and that is the word /// the UI uses — a shell beside its file browser is one thing you arranged, not /// two workspaces that happen to be stacked. A single-pane Workspace is just a workspace and /// names itself after what it holds. /// /// A Workspace is built by combining things that are already open: drag a workspace /// out of the strip and drop it on the edge of a pane, or on an empty slot that /// Split carved. That is the only merge gesture — the workspace strip itself reorders /// and detaches, and never combines. Nothing connects on your behalf as a side /// effect of arranging a layout — see `WindowModel.merge`. /// @Observable final class Workspace: Identifiable { let id = UUID() var sourceTemplateID: WorkspaceTemplate.ID? var layout: SplitNode { didSet { cancelCommandGeneration() synchronizeInputRouters(previousLayout: oldValue) } } var focusedPaneID: Pane.ID { didSet { guard focusedPaneID != oldValue else { return } cancelCommandGeneration() lifecycleRouter?.workspaceContextDidChange(self) } } /// `nil` means the title tracks the focused pane. var customTitle: String? /// The complete name assigned when this became a top-level Workspace. /// Keeping the rendered string rather than a counter makes future allocation /// depend only on the names the user can actually see. var automaticTitle: String? weak var lifecycleRouter: (any WorkspaceLifecycleRouting)? /// Whether typing into one sshSession pane types into all of them. /// /// State of the Workspace rather than of a pane, because the panes *are* /// the group you arranged: a per-pane opt-in would let two shells each /// broadcast to a different set, and then there is no answer to "who hears /// this". Turning it on from any header turns it on for the workspace, and a pane /// dragged in afterwards joins — which is why the button stays visible /// while it is lit instead of hiding with the close button. var broadcastsInput: Bool = false { didSet { guard broadcastsInput != oldValue else { return } cancelCommandGeneration() } } var commandGeneratorState: CommandGeneratorState { get { commandGeneration.state } set { commandGeneration.replaceStateForPresentation(newValue) } } private let commandGeneration = CommandGenerationCoordinator() init(pane: Pane) { self.layout = .leaf(pane) self.focusedPaneID = pane.id synchronizeInputRouters() } var panes: [Pane] { layout.panes } var focusedPane: Pane { layout.pane(focusedPaneID) ?? layout.firstPane } /// Whether this workspace may be combined with another — every pane in it has to /// be something that belongs in a Workspace. An unfilled split fails the /// test, so a Workspace has to be finished before it can be folded into a /// third one. See `PaneKind.isCombinable`. var isCombinable: Bool { panes.allSatisfy(\.isCombinable) } /// Whether a workspace dragged out of the strip could be dropped *into* this one: /// a sshSession, a file browser, or any Workspace of them. /// /// Deliberately wider than `isCombinable`, which asks the opposite question /// and answers no for a half-arranged Workspace. A Workspace with an /// unfilled slot cannot be folded into a third workspace, but it is exactly the /// thing you drag another workspace into — `EmptyPaneView` says so in as many /// words. var acceptsMerge: Bool { panes.contains { $0.isCombinable || $0.isEmptySlot } } /// Whether this workspace may be given a name. /// /// A Workspace always may: it is a group *you* assembled, and "Workspace" is /// a description rather than a name. Otherwise the workspace is its one pane, so /// the pane's rule decides — see `PaneKind.isRenameable`. var isRenameable: Bool { isSplit || focusedPane.kind.isRenameable } var isSplit: Bool { if case .split = layout { return true } return false } var symbol: String { isSplit ? "rectangle.split.2x1" : focusedPane.symbol } var status: ConnectionStatus { focusedPane.status } private var manuallyUnread = false var unreadCompletionCount: Int { max(manuallyUnread ? 1 : 0, panes.reduce(0) { $0 + ($1.commandMonitor?.unreadCompletionCount ?? 0) }) } var hasUnreadCommandCompletion: Bool { unreadCompletionCount > 0 } func markCommandsUnread() { manuallyUnread = true } func markCommandsRead() { manuallyUnread = false for pane in panes { pane.commandMonitor?.markRead() } } var hasUnreadOutput: Bool { panes.contains { $0.hasUnreadOutput } } /// A split workspace is named for itself, not for whichever half you clicked last. /// /// Reading the focused pane meant one workspace wore two names depending on where /// the caret was, which is no name at all. Unnamed, it says "Workspace" — /// the panes are listed individually in the sidebar, so the workspace only has to /// identify the *group*. /// /// A workspace with one pane *is* that pane. A manual pane name wins, followed /// by the complete automatic name assigned when the Workspace opened, then the /// pane's derived Host name. Splitting hides those single-pane names and uses /// the group's `customTitle` or "Workspace" instead. func title(in store: HostLibraryModel, locale: Locale = .current) -> String { if isSplit { return customTitle ?? AppLocalization.resolve(AppStrings.Workspace.workspace, locale: locale) } if let customTitle = focusedPane.customTitle { return customTitle } return automaticTitle ?? focusedPane.derivedTitle(in: store, locale: locale) } var subtitle: String? { if isSplit { let count = panes.count return String(localized: AppStrings.Workspace.panes(count)) } return focusedPane.subtitle } // MARK: - Mutation func focus(_ paneID: Pane.ID) { guard layout.pane(paneID) != nil else { return } guard focusedPaneID != paneID else { return } focusedPaneID = paneID } /// Splits the focused pane and moves focus into the new one. func splitFocusedPane(axis: SplitAxis, with newPane: Pane) { layout = layout.splitting(focusedPaneID, axis: axis, with: newPane) focusedPaneID = newPane.id } /// Moves one divider. The layout is a value type, so this rebuilds the tree /// rather than mutating the branch where it sits. func setFraction(_ fraction: Double, forBranch id: UUID) { layout = layout.settingFraction(fraction, forBranch: id) } /// Drops a whole subtree into the slot `paneID` occupied, and focuses the /// first pane that arrived — dragging something into an empty split should /// leave you in the thing you dragged, not in the hole it filled. func replacePane(_ paneID: Pane.ID, with node: SplitNode) { guard layout.pane(paneID) != nil else { return } layout = layout.replacing(paneID, with: node) focusedPaneID = node.firstPane.id } /// Closes a pane. Returns `false` when that emptied the workspace, which the /// window reads as "close this workspace too". func closePane(_ paneID: Pane.ID) -> Bool { guard let remaining = layout.removing(paneID) else { return false } layout = remaining if layout.pane(focusedPaneID) == nil { focusedPaneID = remaining.firstPane.id } // The name belonged to the group; there is no group left. Keeping it // would hide it behind the surviving pane's name and then spring it back // over unrelated panes the next time this workspace was split. if !isSplit { customTitle = nil } // Same reasoning, and the reason it matters more: a broadcast left set // on a Workspace that lost its second shell would come back live the // next time one was dragged in, and the first thing typed would go // somewhere the user never switched it on for. if !canBroadcast { broadcastsInput = false } return true } func disconnectConnections() { for pane in panes { pane.disconnectConnection() } } /// Preserve the workspace's arrangement and conversation while replacing every /// dead terminal with a fresh model. TerminalPlaceholderView sees the /// connecting state when this Workspace is mounted again and starts the /// ordinary authentication / host-key / SSH connection journey. func prepareForReopen() { for pane in panes { pane.prepareForReopen() } synchronizeInputRouters() } func markRead() { for pane in panes { pane.sshSession?.hasUnreadOutput = false pane.telnetSession?.hasUnreadOutput = false pane.serialSession?.hasUnreadOutput = false } } // MARK: - Broadcast /// The shells a broadcast reaches, in layout order. /// /// Sessions only. A file browser shares the Workspace but not the prompt — /// there is nothing in it for a line of shell to arrive at. var broadcastTargets: [SSHSessionModel] { panes.compactMap(\.sshSession) } /// Whether there is anything to broadcast *to*. A switch with one wire on it /// is a switch that does nothing, so the header hides it and the menu item /// disables rather than offering one. var canBroadcast: Bool { broadcastTargets.count > 1 } /// Sends text to `origin`, and to every other shell in the Workspace when /// broadcast is live. /// /// `canBroadcast` is read here and not only when the switch is flipped: a /// Workspace that has since dropped to one sshSession must deliver to the pane /// that asked, not to a group that no longer exists. func deliver(_ text: String, from origin: SSHSessionModel, submit: Bool = false) { let targets = broadcastsInput && canBroadcast ? broadcastTargets : [origin] for sshSession in targets { sshSession.insert(text, submit: submit) } } func deliverToFocusedTerminal(_ text: String, submit: Bool = false) { if let sshSession = focusedPane.sshSession, sshSession.status.isLive { deliver(text, from: sshSession, submit: submit) } else if let telnetSession = focusedPane.telnetSession, telnetSession.status.isLive { telnetSession.insert(text, submit: submit) } } func generateFocusedCommand( using controller: any CommandGenerating, readViewport: @MainActor (SSHSessionModel) -> String? = { $0.terminalViewOwner.readViewportText() } ) { if commandGeneration.isRunning { cancelCommandGeneration() return } guard let origin = focusedPane.sshSession else { return } let targets = broadcastsInput && canBroadcast ? broadcastTargets : [origin] guard targets.allSatisfy(\.canGenerateFromCurrentDraft) else { showCommandGenerationFailure( String( localized: AppStrings.Workspace.batchCommandGeneratorShellRequirement ) ) return } guard let cwd = origin.workingDirectory, !cwd.isEmpty, let shell = origin.integratedShell else { showCommandGenerationFailure( String(localized: AppStrings.Workspace.theFocusedShellHasNotReportedItsCurrentDirectory) ) return } // Capture before the shell draft request can redraw or scroll the terminal. let viewport = readViewport(origin).map { CommandGeneratorContext.Viewport(text: $0) } let targetIDs = targets.map(\.id) let revisions = Dictionary( uniqueKeysWithValues: targets.map { ($0.id, $0.commandGeneratorRevision) }) let focusedID = focusedPaneID let wasBroadcasting = broadcastsInput && canBroadcast commandGeneration.start( targetCount: targets.count, readContext: { let draft = try await origin.requestCurrentDraft() return CommandGeneratorContext( draft: draft, shell: shell, cwd: cwd, remoteSystemInfo: origin.remoteSystemInfo, viewport: viewport ) }, isCurrent: { [weak self] in self?.commandGenerationSnapshotIsCurrent( focusedID: focusedID, wasBroadcasting: wasBroadcasting, targetIDs: targetIDs, revisions: revisions ) == true }, generate: { try await controller.generate($0) }, insert: { command in for target in targets { target.replaceCurrentDraft(with: command) } } ) } func cancelCommandGeneration() { guard commandGeneration.isRunning else { return } commandGeneration.cancel() } private func commandGenerationSnapshotIsCurrent( focusedID: Pane.ID, wasBroadcasting: Bool, targetIDs: [SSHSessionModel.ID], revisions: [SSHSessionModel.ID: UInt64] ) -> Bool { guard focusedPaneID == focusedID, (broadcastsInput && canBroadcast) == wasBroadcasting else { return false } let targets = wasBroadcasting ? broadcastTargets : focusedPane.sshSession.map { [$0] } ?? [] guard targets.map(\.id) == targetIDs else { return false } return targets.allSatisfy { $0.canGenerateFromCurrentDraft && revisions[$0.id] == $0.commandGeneratorRevision } } private func showCommandGenerationFailure(_ message: String) { commandGeneration.fail(message) } /// Keeps each session-to-Workspace relationship correct as panes are merged, /// detached, replaced, and tiled. Removed sessions are cleared only when /// this Workspace still owns their route; a destination may already have /// claimed them during a tree rewrite. private func synchronizeInputRouters(previousLayout: SplitNode? = nil) { let currentSessions = broadcastTargets let currentIDs = Set(currentSessions.map(\.id)) let currentSFTPChannels = panes.compactMap(\.sftpChannel) let currentSFTPIDs = Set(currentSFTPChannels.map(\.id)) if let previousLayout { for sshSession in previousLayout.panes.compactMap(\.sshSession) where !currentIDs.contains(sshSession.id) && sshSession.inputRouter === self { sshSession.inputRouter = nil } for sftpChannel in previousLayout.panes.compactMap(\.sftpChannel) where !currentSFTPIDs.contains(sftpChannel.id) && sftpChannel.navigationRouter === self { sftpChannel.navigationRouter = nil } } for sshSession in currentSessions { sshSession.inputRouter = self } for sftpChannel in currentSFTPChannels { sftpChannel.navigationRouter = self } if !canBroadcast { broadcastsInput = false } } } extension Workspace: SFTPChannelNavigationRouting { func sftpChannel(_ sftpChannel: SFTPChannelModel, didChangePath path: String) { guard focusedPane.sftpChannel === sftpChannel else { return } lifecycleRouter?.workspaceContextDidChange(self) } } extension Workspace: SSHSessionInputRouting { /// Copies the exact bytes Ghostty produced for a keyboard or paste action. /// Sending directly preserves each event's terminal encoding and avoids /// feeding broadcast copies back through this router. func routeInteractiveInput(_ data: Data, from origin: SSHSessionModel) { cancelCommandGeneration() let sessions = broadcastTargets guard sessions.contains(where: { $0 === origin }) else { origin.writeInputDirectly(data) return } let targets = broadcastsInput && sessions.count > 1 ? sessions : [origin] for sshSession in targets { sshSession.writeInputDirectly(data) } } func sshSessionDidInvalidateCommandGeneration(_: SSHSessionModel) { cancelCommandGeneration() } /// A background pane may keep reporting OSC 7 while another split has /// focus. Only the focused shell is allowed to change the Context shown by /// the Assistant sidebar. func sshSession( _ sshSession: SSHSessionModel, didChangeWorkingDirectory workingDirectory: String? ) { guard focusedPane.sshSession === sshSession else { return } lifecycleRouter?.workspaceContextDidChange(self) } func sshSessionDidChangeRemoteSystemInfo(_ sshSession: SSHSessionModel) { guard focusedPane.sshSession === sshSession else { return } lifecycleRouter?.workspaceContextDidChange(self) } /// Shell EOF is a successful completion, so it closes the shell's pane. /// The window owns the last-pane case because removing it also removes and /// reselects workspaces. Standalone Workspaces still handle split panes in tests /// and previews. func closeSSHSessionPane(afterExit sshSession: SSHSessionModel) { guard let pane = panes.first(where: { $0.sshSession === sshSession }) else { return } if let lifecycleRouter { lifecycleRouter.workspace(self, closePaneAfterSSHSessionExit: pane.id) } else { _ = closePane(pane.id) } } } // MARK: - Convenience constructors extension Workspace { func configureConnections(using coordinator: RemoteConnectionCoordinator) { for pane in panes { pane.configureConnections(using: coordinator) } } /// A host is required: there is no local terminal to open without one. static func sshSession(host: Host, usernameHint: String? = nil) -> Workspace { Workspace(pane: .sshSession(host: host, usernameHint: usernameHint)) } /// A host is required, for the same reason a sshSession needs one: there is /// nothing to browse without a machine on the other end. static func sftpChannel(host: Host, initialPath: String? = nil) -> Workspace { Workspace(pane: .sftpChannel(host: host, initialPath: initialPath)) } }