Files
Termind/TFM/Features/Workspaces/Pane.swift
T

679 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
/// What a single pane is showing.
///
/// The plan called this `WorkspaceContent`, but splitting means a workspace can hold several
/// of these at once, so the unit of content is the pane and a workspace owns a tree of
/// them. A workspace's title, icon, and status are derived from its *focused* pane.
///
enum PaneContent {
// Work, created on demand and listed in the sidebar.
//
// `sftpChannel` requires a host — there is no host-less SFTP channel,
// because a transfer is a conversation with one machine about one path.
case sshSession(SSHSessionModel)
case telnetSession(TelnetSessionModel)
case sftpChannel(SFTPChannelModel)
case serialSession(SerialSessionModel)
/// A slot that has been carved out and not filled yet.
///
/// Split makes one of these rather than opening a connection, because
/// choosing a layout and choosing a machine are two decisions and only one
/// of them was asked for. What fills it has to be something already open:
/// drag a workspace out of the strip and drop it here, or move a pane over from
/// elsewhere in this same Workspace.
case empty
}
/// Which sidebar section, if any, a pane belongs under.
///
/// Introduced because "everything that is not a page is a session" was wrong in
/// two directions at once: it filed the New Host form under Sessions, and it had
/// nowhere to put a conversation that is not attached to a host.
nonisolated enum PaneKind: Hashable, Sendable {
/// A shell on a host. This is what "Session" means in the sidebar, and it
/// means only that — a machine's tunnels used to land here too, which put a
/// second row under Sessions for a host you already had a shell on.
case sshSession
case telnetSession
/// One host's SFTP browser. It shares the Sessions section with SSH while
/// retaining its own kind for protocol-specific actions and iconography.
case sftpChannel
/// A temporary terminal attached directly to a local serial device.
case serialSession
/// An unfilled split. Belongs in no sidebar section — it is a hole in a
/// layout, and listing it would put a row in Sessions for something that is
/// not a session yet and may never be one.
case empty
/// Whether a pane of this kind may share a Workspace with others.
///
/// Work you do side by side: a shell, a file browser, a conversation. The
/// chat belongs here for the reason the app exists — a conversation is
/// *about* something, and the thing it is about is usually the pane next to
/// it. Refusing to let those two sit together made the one arrangement this
/// app is for the one arrangement you could not build.
///
/// Management and forms live outside this type. This gates dragging,
/// dropping, Split, Duplicate, and whether those items appear in a menu.
///
/// `.empty` is not combinable, which is what stops a half-arranged Workspace
/// from being dragged into another one before it has been filled.
var isCombinable: Bool {
self == .sshSession || self == .telnetSession || self == .sftpChannel || self == .serialSession
}
/// Whether a pane of this kind may be given a name.
///
/// Work, and only work: a shell, a file browser, a conversation. Those are
/// things you have several of, so which one is which is a question you can
/// answer for yourself and nobody else can.
///
/// A manager page is a destination rather than a thing you own — there is
/// one Keychain and it is called Keychain — and renaming it would put a workspace
/// in the bar that no longer matches the sidebar row that opens it. A form
/// and an unfilled slot are the same story: both are named after the state
/// they are in, and both stop existing when that state ends.
var isRenameable: Bool {
self == .sshSession || self == .telnetSession || self == .sftpChannel || self == .serialSession
}
/// Only work panes have transient context worth reserving a footer for.
/// Manager pages, forms and empty slots should use the full content height
/// instead of permanently reporting the fallback "Not connected" state.
var showsStatusBar: Bool {
self == .sshSession || self == .telnetSession || self == .sftpChannel || self == .serialSession
}
}
/// One rectangle of content inside a workspace.
@Observable
final class Pane: Identifiable {
let id = UUID()
// Reopening can reuse Telnet/serial models. Old notifications must still
// refer to the closed session, while moving a pane keeps this identity.
private var commandNotificationID = UUID()
var content: PaneContent
private var connectionHistoryEntryID: ConnectionHistoryEntry.ID?
private var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
/// A name the user gave this pane. `nil` means the title tracks the content
/// — the host it is on, the page it shows.
///
/// Panes are nameable because a Workspace can hold three shells on one
/// machine, and "web-01", "web-01", "web-01" in the sidebar is three rows
/// you have to click to tell apart.
var customTitle: String?
init(_ content: PaneContent) {
self.content = content
}
func configureConnections(using coordinator: RemoteConnectionCoordinator) {
switch content {
case .sshSession(let session): session.connectionCoordinator = coordinator
case .telnetSession: break
case .sftpChannel(let session): session.connectionCoordinator = coordinator
case .serialSession: break
case .empty: break
}
}
func configureConnectionHistory(
entryID: ConnectionHistoryEntry.ID,
end: @escaping (ConnectionHistoryEntry.ID) -> Void
) {
connectionHistoryEntryID = entryID
endConnectionHistory = end
}
var hasActiveConnectionHistory: Bool { connectionHistoryEntryID != nil }
var kind: PaneKind {
switch content {
case .sftpChannel: .sftpChannel
case .sshSession: .sshSession
case .telnetSession: .telnetSession
case .serialSession: .serialSession
case .empty: .empty
}
}
/// Whether this pane may share a Workspace with others — see `PaneKind`.
var isCombinable: Bool { kind.isCombinable }
/// Whether this pane is an unfilled split slot, which accepts a drop by
/// being replaced rather than split.
var isEmptySlot: Bool {
if case .empty = content { return true }
return false
}
var symbol: String {
switch content {
case .sshSession: "apple.terminal"
case .telnetSession: MenuSymbol.telnet
case .sftpChannel: "folder"
case .serialSession: "cable.connector"
case .empty: "rectangle.dashed"
}
}
/// The shell inside this pane, if it is one. Spelled once here rather than
/// as an `if case` at each of the four places that need it.
var sshSession: SSHSessionModel? {
if case .sshSession(let sshSession) = content { return sshSession }
return nil
}
var sftpChannel: SFTPChannelModel? {
if case .sftpChannel(let sftpChannel) = content { return sftpChannel }
return nil
}
var telnetSession: TelnetSessionModel? {
if case .telnetSession(let telnetSession) = content { return telnetSession }
return nil
}
var serialSession: SerialSessionModel? {
if case .serialSession(let serialSession) = content { return serialSession }
return nil
}
/// Ends network state only when the pane is actually being closed. View
/// disappearance is not ownership: dragging a pane reparents its view and
/// must leave the same live session connected.
func disconnectConnection() {
if let connectionHistoryEntryID {
endConnectionHistory?(connectionHistoryEntryID)
self.connectionHistoryEntryID = nil
endConnectionHistory = nil
}
switch content {
case .sshSession(let sshSession):
sshSession.commandHistory.close()
sshSession.disconnect()
case .telnetSession(let telnetSession): telnetSession.disconnect()
case .sftpChannel(let sftpChannel):
sftpChannel.history.close()
sftpChannel.disconnect()
case .serialSession(let serialSession): serialSession.disconnect()
default: break
}
}
/// Reopening a closed terminal means starting another SSH session, not
/// remounting the model whose session and connection task were torn down by
/// `disconnectConnection`. Keep the Pane itself so its name, duplicate
/// index, focus identity, and place in the split tree all survive.
func prepareForReopen() {
commandNotificationID = UUID()
commandMonitor?.markRead()
switch content {
case .sshSession(let sshSession):
sshSession.inputRouter = nil
let replacement = sshSession.copyForReconnect()
replacement.status = .connecting
content = .sshSession(replacement)
case .telnetSession(let telnetSession):
telnetSession.prepareForReopen()
case .serialSession(let serialSession):
serialSession.prepareForReopen()
case .sftpChannel(let sftpChannel):
sftpChannel.prepareForReopen()
case .empty:
break
}
}
var status: ConnectionStatus {
switch content {
case .sshSession(let sshSession): sshSession.status
case .telnetSession(let telnetSession): telnetSession.status
case .sftpChannel(let sftpChannel): sftpChannel.status
case .serialSession(let serialSession): serialSession.status
default: .disconnected
}
}
func readViewportText() -> String? {
switch content {
case .sshSession(let session): session.terminalViewOwner.readViewportText()
case .telnetSession(let session): session.terminalViewOwner.readViewportText()
case .serialSession(let session): session.terminalViewOwner.readViewportText()
case .sftpChannel, .empty: nil
}
}
var commandSessionID: UUID? {
commandMonitor == nil ? nil : commandNotificationID
}
var commandMonitor: TerminalCommandMonitor? {
sshSession?.commandMonitor ?? telnetSession?.commandMonitor ?? serialSession?.commandMonitor
}
var hasUnreadOutput: Bool {
switch content {
case .sshSession(let sshSession): sshSession.hasUnreadOutput
case .telnetSession(let telnetSession): telnetSession.hasUnreadOutput
case .serialSession(let serialSession): serialSession.hasUnreadOutput
default: false
}
}
/// The host this pane is attached to, if any — drives the status bar and the
/// Assistant sidebar's default context chips.
var hostID: Host.ID? {
switch content {
case .sshSession(let sshSession): sshSession.hostID
case .telnetSession(let telnetSession): telnetSession.hostID
case .sftpChannel(let sftpChannel): sftpChannel.hostID
case .serialSession: nil
case .empty: nil
}
}
var workingDirectory: String? {
switch content {
case .sshSession(let sshSession): sshSession.workingDirectory
case .telnetSession: nil
case .sftpChannel(let sftpChannel): sftpChannel.path
case .serialSession: nil
default: nil
}
}
var displayedWorkingDirectory: String? {
switch content {
case .sshSession(let sshSession): sshSession.displayedWorkingDirectory
case .telnetSession: nil
case .sftpChannel(let sftpChannel): sftpChannel.displayedPath
case .serialSession: nil
default: nil
}
}
var remoteSystemInfo: RemoteSystemInfo? {
guard case .sshSession(let sshSession) = content else { return nil }
return sshSession.remoteSystemInfo
}
/// A fresh pane of the same kind on the same host — a second shell, a second
/// browser, a second unfilled slot. Nil for anything a Workspace cannot
/// hold, which is everything Duplicate is not offered on anyway.
///
/// The name deliberately does not come along. Copying "logs" would give two
/// panes both called "logs". A top-level Workspace assigns the copy a fresh
/// automatic name after it leaves the layout.
///
/// `.empty` copies as another empty slot rather than failing, so a
/// half-arranged Workspace can still be duplicated as the half-arranged
/// thing it is.
func copy() -> Pane? {
switch content {
case .sshSession(let sshSession):
let clone = sshSession.copyForReconnect()
clone.status = .connecting
return Pane(.sshSession(clone))
case .telnetSession(let telnetSession):
guard let clone = telnetSession.copyForReconnect() else { return nil }
clone.status = .connecting
return Pane(.telnetSession(clone))
case .sftpChannel(let sftpChannel):
return Pane(.sftpChannel(sftpChannel.copyForBrowsing()))
case .serialSession:
return nil
case .empty:
return Pane(.empty)
}
}
/// Resolved against the store so a renamed host renames its workspaces — unless
/// the user named this pane, in which case their word wins everywhere.
func title(in store: HostLibraryModel, locale: Locale = .current) -> String {
if let customTitle { return customTitle }
return derivedTitle(in: store, locale: locale)
}
func derivedTitle(in store: HostLibraryModel, locale: Locale) -> String {
switch content {
case .sshSession(let sshSession):
sshSession.title
case .telnetSession(let telnetSession):
telnetSession.title
case .sftpChannel(let sftpChannel):
store.host(sftpChannel.hostID)?.name
?? AppLocalization.resolve(AppStrings.SFTP.sftp, locale: locale)
case .serialSession(let serialSession):
serialSession.title
case .empty:
AppLocalization.resolve(AppStrings.Common.empty, locale: locale)
}
}
/// The title with the sidebar section's own word taken back out.
///
/// SFTP and SSH panes share Sessions, with their symbols distinguishing the
/// protocol. The row therefore keeps the host name concise and unprefixed.
func sidebarTitle(in store: HostLibraryModel, locale: Locale = .current) -> String {
if let customTitle { return customTitle }
if case .sftpChannel(let sftpChannel) = content,
let host = store.host(sftpChannel.hostID)
{
return host.name
}
return derivedTitle(in: store, locale: locale)
}
func subtitle(in store: HostLibraryModel) -> String? {
switch content {
case .sftpChannel(let sftpChannel):
sftpChannel.displayedPath
case .serialSession(let serialSession):
serialSession.subtitle
case .sshSession(let sshSession):
// Like SFTP navigation, the shell's live directory remains useful
// context after the user gives the pane a stable name.
sshSession.status.isLive ? sshSession.displayedWorkingDirectory : nil
case .telnetSession:
nil
default:
nil
}
}
}
// MARK: - Convenience constructors
extension Pane {
/// One shell on a host, already marked as connecting.
///
/// Here rather than inside `Workspace.sshSession(host:)` because a session pane
/// is now born in two places — a workspace of its own, and one cell of a batch
/// Workspace — and a second copy of this is a second chance to forget the
/// status flip.
static func sshSession(
host: Host, usernameHint: String? = nil, initialCommand: String? = nil
) -> Pane {
sshSession(
host: host, usernameHint: usernameHint, initialCommand: initialCommand,
recordsInitialCommandInHistory: true
)
}
static func sshSession(
host: Host, usernameHint: String?, initialCommand: String?,
recordsInitialCommandInHistory: Bool
) -> Pane {
let sshSession = SSHSessionModel(
host: host, usernameHint: usernameHint, initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory
)
sshSession.status = .connecting
return Pane(.sshSession(sshSession))
}
static func sftpChannel(host: Host, initialPath: String? = nil) -> Pane {
Pane(
.sftpChannel(
SFTPChannelModel(
hostID: host.id, initialPath: initialPath ?? host.sftpChannelInitialPath
)))
}
static func telnetSession(
host: Host, usernameHint: String? = nil,
advisoryPassword: SSHSecret? = nil,
isExternalTemporaryConnection: Bool = false
) -> Pane {
let session = TelnetSessionModel(
host: host, usernameHint: usernameHint,
advisoryPassword: advisoryPassword,
isExternalTemporaryConnection: isExternalTemporaryConnection)
session.status = ConnectionStatus.connecting
return Pane(.telnetSession(session))
}
static func serialSession(
configuration: SerialConnectionConfiguration, service: any SerialPortServing
) -> Pane {
let session = SerialSessionModel(configuration: configuration, service: service)
session.status = .connecting
return Pane(.serialSession(session))
}
}
nonisolated enum SplitAxis: Hashable, Sendable {
/// Panes sit side by side (`HSplitView`).
case horizontal
/// Panes stack top and bottom (`VSplitView`).
case vertical
}
/// Which side of the target a new pane or subtree lands on.
///
/// `splitting` has always put the newcomer second, which was enough while the
/// only caller was Split. Dropping on the *leading* edge of a pane has to be able
/// to put it first, or the preview rectangle lies about where the thing will go.
nonisolated enum SplitSide: Hashable, Sendable {
case before
case after
}
/// A workspace's layout: a binary tree of panes.
///
/// The tree is recursive so nested splits need no model change later.
indirect enum SplitNode {
case leaf(Pane)
case split(Branch)
/// One division of a Workspace: an axis, the share the first child takes,
/// and the two subtrees.
///
/// The fraction is stored here rather than left to `HSplitView`, which owned
/// its divider position and handed leftover space to whichever child asked
/// last. Two consequences of that we needed gone: a drop previewed as "the
/// right half" landed at whatever width AppKit chose, and resizing the workspace
/// grew one pane while the other stayed where it was. Reading the layout
/// from a stored ratio makes both exact — the preview is a promise the
/// layout keeps, and every size is recomputed proportionally on resize.
///
/// `id` exists so a divider drag can write a new fraction back into a tree
/// that is otherwise all value types.
struct Branch: Identifiable {
let id: UUID
var axis: SplitAxis
/// The first child's share of the split axis, 0…1.
var fraction: Double
var first: SplitNode
var second: SplitNode
init(
id: UUID = UUID(),
axis: SplitAxis,
fraction: Double = 0.5,
first: SplitNode,
second: SplitNode
) {
self.id = id
self.axis = axis
self.fraction = fraction
self.first = first
self.second = second
}
}
/// A new split, always down the middle — which is the rectangle the drop
/// preview drew, and the only honest starting point for one.
static func split(axis: SplitAxis, first: SplitNode, second: SplitNode) -> SplitNode {
.split(Branch(axis: axis, first: first, second: second))
}
/// Tiles panes into a balanced grid: halve the list, and turn the axis a
/// quarter each level down. Two panes sit side by side, three are one on the
/// left and two stacked on the right, four make a 2×2.
///
/// The fraction is each side's *share of the panes* rather than a flat half,
/// which is what makes every leaf come out the same area — with three, a flat
/// half would give the lone pane half the window and the pair a quarter each.
///
/// A row of N columns was the alternative and is unusable past a handful:
/// `Metrics.Split.minPaneLength` is 160pt, so a default window runs out of
/// room at seven, and seven terminals in a row is not something anyone reads.
static func tiled(_ panes: [Pane], axis: SplitAxis = .horizontal) -> SplitNode? {
guard let only = panes.first else { return nil }
guard panes.count > 1 else { return .leaf(only) }
let cut = panes.count / 2
let head = Array(panes[..<cut])
let tail = Array(panes[cut...])
let next: SplitAxis = axis == .horizontal ? .vertical : .horizontal
guard let first = tiled(head, axis: next), let second = tiled(tail, axis: next) else {
return .leaf(only)
}
return .split(
Branch(
axis: axis,
fraction: Double(head.count) / Double(panes.count),
first: first,
second: second
)
)
}
var panes: [Pane] {
switch self {
case .leaf(let pane): [pane]
case .split(let branch): branch.first.panes + branch.second.panes
}
}
/// The same tree with new panes in it: shape, axes and fractions preserved,
/// every leaf replaced by `Pane.copy()`. Nil if any leaf refuses to copy.
///
/// Fractions come along because they *are* the arrangement — a duplicate
/// that resets every divider to the middle is not the Workspace you asked to
/// duplicate. Branch ids do not: they exist so a divider drag can find the
/// branch it grabbed, and two trees answering to one id is the kind of thing
/// that works right up until it does not.
func copied() -> SplitNode? {
switch self {
case .leaf(let pane):
return pane.copy().map(SplitNode.leaf)
case .split(let branch):
guard let first = branch.first.copied(), let second = branch.second.copied() else {
return nil
}
return .split(
Branch(
axis: branch.axis,
fraction: branch.fraction,
first: first,
second: second
)
)
}
}
var firstPane: Pane {
switch self {
case .leaf(let pane): pane
case .split(let branch): branch.first.firstPane
}
}
func pane(_ id: Pane.ID) -> Pane? {
panes.first { $0.id == id }
}
/// Rewrites one branch's fraction. The tree is a value, so a divider drag
/// has to rebuild the path down to the branch it moved rather than mutating
/// it where it sits.
func settingFraction(_ fraction: Double, forBranch id: UUID) -> SplitNode {
guard case .split(var branch) = self else { return self }
if branch.id == id {
branch.fraction = fraction
} else {
branch.first = branch.first.settingFraction(fraction, forBranch: id)
branch.second = branch.second.settingFraction(fraction, forBranch: id)
}
return .split(branch)
}
/// Replaces the leaf holding `paneID` with a split containing it and `newPane`.
func splitting(
_ paneID: Pane.ID,
axis: SplitAxis,
side: SplitSide = .after,
with newPane: Pane
) -> SplitNode {
splitting(paneID, axis: axis, side: side, with: .leaf(newPane))
}
/// The same, with a whole subtree on the new side — a merge folds another
/// Workspace's entire layout in, not just one of its panes.
func splitting(
_ paneID: Pane.ID,
axis: SplitAxis,
side: SplitSide = .after,
with node: SplitNode
) -> SplitNode {
switch self {
case .leaf(let pane):
guard pane.id == paneID else { return self }
return switch side {
case .after: .split(axis: axis, first: .leaf(pane), second: node)
case .before: .split(axis: axis, first: node, second: .leaf(pane))
}
case .split(var branch):
branch.first = branch.first.splitting(paneID, axis: axis, side: side, with: node)
branch.second = branch.second.splitting(paneID, axis: axis, side: side, with: node)
return .split(branch)
}
}
/// Swaps the leaf holding `paneID` for a whole subtree — how a dragged
/// Workspace's panes land in an empty slot. The inverse of `splitting`,
/// which only ever inserts one pane.
func replacing(_ paneID: Pane.ID, with node: SplitNode) -> SplitNode {
switch self {
case .leaf(let pane):
return pane.id == paneID ? node : self
case .split(var branch):
branch.first = branch.first.replacing(paneID, with: node)
branch.second = branch.second.replacing(paneID, with: node)
return .split(branch)
}
}
/// Removes a pane, collapsing the split that held it. Returns `nil` when the
/// tree becomes empty, which is the caller's signal to close the workspace.
func removing(_ paneID: Pane.ID) -> SplitNode? {
switch self {
case .leaf(let pane):
return pane.id == paneID ? nil : self
case .split(var branch):
switch (branch.first.removing(paneID), branch.second.removing(paneID)) {
case (nil, nil):
return nil
case (let remaining?, nil), (nil, let remaining?):
// The surviving side takes the whole space, so the branch — and
// the fraction the user set on it — goes with the pane that left.
return remaining
case (let a?, let b?):
branch.first = a
branch.second = b
return .split(branch)
}
}
}
}