Files
Termind/TFM/Features/Workspaces/Workspace.swift
T
2026-09-11 21:07:44 +08:00

538 lines
22 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
protocol WorkspaceLifecycleRouting: AnyObject {
func workspace(_ workspace: Workspace, closePaneAfterSSHSessionExit paneID: Pane.ID)
func workspaceContextDidChange(_ workspace: Workspace)
func workspaceRemoteWindowTitleDidChange(_ 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 layout: SplitNode {
didSet {
cancelCommandGeneration()
synchronizeInputRouters(previousLayout: oldValue)
}
}
var focusedPaneID: Pane.ID {
didSet {
guard focusedPaneID != oldValue else { return }
cancelCommandGeneration()
lifecycleRouter?.workspaceContextDidChange(self)
lifecycleRouter?.workspaceRemoteWindowTitleDidChange(self)
}
}
/// `nil` means the title tracks the focused pane.
var customTitle: 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 = .idle
private var commandGeneratorTask: Task<Void, Never>?
private var commandGeneratorDismissTask: Task<Void, Never>?
private var commandGenerationSequence: UInt64 = 0
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 }
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, so it takes the pane's name and never
/// consults `customTitle`: renaming such a workspace writes through to the pane
/// (see `WindowModel.rename`), and one thing has one name. It also means a
/// renamed workspace reads "Workspace" the moment it is split, since the name it
/// was given went to the pane rather than to the group.
func title(in store: HostLibraryModel, locale: Locale = .current) -> String {
if isSplit {
return customTitle ?? AppLocalization.string("Workspace", locale: locale)
}
return focusedPane.title(in: store, locale: locale)
}
func subtitle(in store: HostLibraryModel) -> String? {
if isSplit {
let count = panes.count
return String(localized: "\(count) panes")
}
return focusedPane.subtitle(in: store)
}
// 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.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 generateFocusedCommand(using controller: any CommandGenerating) {
if commandGeneratorTask != nil {
cancelCommandGeneration()
return
}
guard let origin = focusedPane.sshSession else { return }
let targets = broadcastsInput && canBroadcast ? broadcastTargets : [origin]
guard targets.allSatisfy(\.canGenerateFromCurrentDraft) else {
showCommandGenerationFailure(
String(
localized:
"Command Generator requires every target to be at a supported shell prompt with Shell Integration enabled."
)
)
return
}
guard let cwd = origin.workingDirectory, !cwd.isEmpty,
let shell = origin.integratedShell
else {
showCommandGenerationFailure(
String(localized: "The focused shell has not reported its current directory.")
)
return
}
commandGeneratorDismissTask?.cancel()
commandGenerationSequence &+= 1
let generation = commandGenerationSequence
let targetIDs = targets.map(\.id)
let revisions = Dictionary(
uniqueKeysWithValues: targets.map {
($0.id, $0.commandGeneratorRevision)
})
let focusedID = focusedPaneID
let wasBroadcasting = broadcastsInput && canBroadcast
commandGeneratorState = .readingDraft
commandGeneratorTask = Task { [weak self, controller] in
do {
let draft = try await origin.requestCurrentDraft()
try Task.checkCancellation()
guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw WorkspaceCommandGenerationError.emptyDraft
}
guard
self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true
else { throw CancellationError() }
self?.commandGeneratorState = .generating
let context = CommandGeneratorContext(
draft: draft, shell: shell, cwd: cwd,
remoteSystemInfo: SettingsKey.isShellIntegrationEnabled
? origin.remoteSystemInfo : nil
)
let command = try await commandGenerationWithTimeout {
try await controller.generate(context)
}
try Task.checkCancellation()
guard
self?.commandGenerationSnapshotIsCurrent(
generation: generation, focusedID: focusedID,
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
revisions: revisions
) == true
else { throw CancellationError() }
for target in targets {
target.replaceCurrentDraft(with: command)
}
self?.finishCommandGeneration(
generation: generation,
state: .inserted(targetCount: targets.count), dismissAfter: .seconds(2)
)
} catch is CancellationError {
self?.finishCancelledCommandGeneration(generation: generation)
} catch {
self?.finishCommandGeneration(
generation: generation,
state: .failed(error.localizedDescription), dismissAfter: .seconds(3)
)
}
}
}
func cancelCommandGeneration() {
guard commandGeneratorTask != nil else { return }
commandGenerationSequence &+= 1
commandGeneratorTask?.cancel()
commandGeneratorTask = nil
commandGeneratorDismissTask?.cancel()
commandGeneratorDismissTask = nil
commandGeneratorState = .idle
}
private func commandGenerationSnapshotIsCurrent(
generation: UInt64, focusedID: Pane.ID, wasBroadcasting: Bool,
targetIDs: [SSHSessionModel.ID], revisions: [SSHSessionModel.ID: UInt64]
) -> Bool {
guard commandGenerationSequence == generation,
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 finishCancelledCommandGeneration(generation: UInt64) {
guard commandGenerationSequence == generation else { return }
commandGeneratorTask = nil
commandGeneratorState = .idle
}
private func finishCommandGeneration(
generation: UInt64, state: CommandGeneratorState, dismissAfter: Duration
) {
guard commandGenerationSequence == generation else { return }
commandGeneratorTask = nil
commandGeneratorState = state
scheduleCommandGenerationDismiss(generation: generation, after: dismissAfter)
}
private func showCommandGenerationFailure(_ message: String) {
commandGenerationSequence &+= 1
commandGeneratorTask?.cancel()
commandGeneratorTask = nil
commandGeneratorDismissTask?.cancel()
commandGeneratorState = .failed(message)
scheduleCommandGenerationDismiss(
generation: commandGenerationSequence, after: .seconds(3)
)
}
private func scheduleCommandGenerationDismiss(generation: UInt64, after delay: Duration) {
commandGeneratorDismissTask?.cancel()
commandGeneratorDismissTask = Task { [weak self] in
try? await Task.sleep(for: delay)
guard !Task.isCancelled, self?.commandGenerationSequence == generation else { return }
self?.commandGeneratorState = .idle
self?.commandGeneratorDismissTask = nil
}
}
/// 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 currentSFTPSessions = panes.compactMap(\.sftpSession)
let currentSFTPIDs = Set(currentSFTPSessions.map(\.id))
if let previousLayout {
for sshSession in previousLayout.panes.compactMap(\.sshSession)
where !currentIDs.contains(sshSession.id) && sshSession.inputRouter === self {
sshSession.inputRouter = nil
}
for sftpSession in previousLayout.panes.compactMap(\.sftpSession)
where !currentSFTPIDs.contains(sftpSession.id) && sftpSession.navigationRouter === self {
sftpSession.navigationRouter = nil
}
}
for sshSession in currentSessions {
sshSession.inputRouter = self
}
for sftpSession in currentSFTPSessions {
sftpSession.navigationRouter = self
}
if !canBroadcast { broadcastsInput = false }
}
}
extension Workspace: SFTPSessionNavigationRouting {
func sftpSession(_ sftpSession: SFTPSessionModel, didChangePath path: String) {
guard focusedPane.sftpSession === sftpSession 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)
}
func sshSessionDidChangeRemoteWindowTitle(_ sshSession: SSHSessionModel) {
guard focusedPane.sshSession === sshSession else { return }
lifecycleRouter?.workspaceRemoteWindowTitleDidChange(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)
}
}
}
nonisolated private enum WorkspaceCommandGenerationError: LocalizedError {
case emptyDraft
var errorDescription: String? {
String(localized: "Type something at the shell prompt before generating a command.")
}
}
nonisolated private func commandGenerationWithTimeout(
_ operation: @escaping @Sendable () async throws -> String
) async throws -> String {
try await withThrowingTaskGroup(of: String.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: .seconds(30))
throw CommandGeneratorError.timedOut
}
guard let result = try await group.next() else {
throw CommandGeneratorError.invalidResponse
}
group.cancelAll()
return result
}
}
// MARK: - Convenience constructors
extension Workspace {
func configureConnections(using coordinator: RemoteConnectionCoordinator) {
for pane in panes { pane.configureConnections(using: coordinator) }
}
func configureCommandHistory(using store: CommandHistoryModel) {
for pane in panes { pane.configureCommandHistory(using: store) }
}
func configureSFTPHistory(using store: SFTPHistoryModel) {
for pane in panes { pane.configureSFTPHistory(using: store) }
}
/// 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 sftpSession(host: Host, initialPath: String? = nil) -> Workspace {
Workspace(pane: .sftpSession(host: host, initialPath: initialPath))
}
}