466 lines
19 KiB
Swift
466 lines
19 KiB
Swift
// 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))
|
|
}
|
|
|
|
}
|