Files
Termind/TFM/Features/Workspaces/WorkspaceContentView.swift

1409 lines
54 KiB
Swift
Raw Permalink 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.
// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import GhosttyTerminal
import GlyphixTextFx
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
#if canImport(AppKit)
import AppKit
#endif
/// The body of a Workspace: its pane tree, laid out with split views.
struct WorkspaceContentView: View {
let workspace: Workspace
let isActive: Bool
@AppStorage(DesktopSettingsKey.showStatusBar) private var showsStatusBar = true
@Environment(WindowModel.self) private var window
var body: some View {
ZStack(alignment: .bottom) {
SplitNodeView(
node: workspace.layout,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
.id(workspace.id)
if workspace.commandGeneratorState != .idle {
CommandGeneratorOverlay(state: workspace.commandGeneratorState)
.padding(Metrics.Space.l)
// Above the floating status capsule, not on top of it.
.padding(
.bottom,
Metrics.StatusBar.overlayClearance(isEnabled: showsStatusBar)
)
.zIndex(20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(Motion.panel, value: workspace.commandGeneratorState)
.background {
broadcastShortcut
terminalFindShortcuts
}
}
/// ⌘B, with no menu item to hang it on.
///
/// Broadcast has a home already — the button on every shell's pane header,
/// which sits next to the panes it reaches and shows its own state. A menu
/// item would be a second copy of a control that is visible whenever it is
/// usable, so the shortcut is registered as a zero-size button that exists
/// only to own a key equivalent while this workspace is on screen.
///
/// Disabled when there is nothing to broadcast to, so the key falls through
/// rather than flipping a switch with one wire on it.
private var broadcastShortcut: some View {
Button("") { window.toggleBroadcastInput(in: workspace) }
.termindShortcut("b")
.disabled(!isActive || !workspace.canBroadcast)
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
/// Function-key and cancel equivalents have no natural menu item. They live
/// beside the existing invisible ⌘B owner and are disabled on every hidden
/// workspace, so AppKit never has to choose between two mounted sessions.
@ViewBuilder
private var terminalFindShortcuts: some View {
#if canImport(AppKit)
let sshSession = workspace.focusedPane.sshSession
Group {
// F3 is ⌘G's twin and carries the same mapping: `.previous`
// walks toward older output, which is the direction that finds
// matches when a search starts at the bottom of the buffer.
Button("") { sshSession?.find.presentAndNavigate(.previous) }
.termindShortcut(.f3, modifiers: [])
.disabled(!isActive || sshSession == nil)
Button("") { sshSession?.find.presentAndNavigate(.next) }
.termindShortcut(.f3, modifiers: .shift)
.disabled(!isActive || sshSession == nil)
Button("") { sshSession?.find.dismiss() }
.termindShortcut(.escape, modifiers: [])
.disabled(!isActive || sshSession?.find.isPresented != true)
Button("") { sshSession?.find.dismiss() }
.termindShortcut(.escape, modifiers: .shift)
.disabled(!isActive || sshSession?.find.isPresented != true)
}
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
#endif
}
/// While one of this Workspace's panes is being dragged, its branch collapses
/// without removing the source view from the hierarchy.
///
/// So the arrangement under the pointer is the one you would actually get:
/// the remaining panes have already reflowed into the space the dragged one
/// is leaving, and the drop rectangle lands on that rather than on a layout
/// you are in the middle of abandoning.
///
/// Keeping the original `PaneView` mounted is required by AppKit: removing
/// the view that owns the drag can end the native drag session before the
/// pointer reaches the Navigator. `SplitBranchView` uses this identity only
/// for temporary geometry; the model tree is unchanged until a drop lands.
private var omittedPaneID: Pane.ID? {
guard case .pane(let id)? = window.dragging,
workspace.layout.pane(id) != nil
else { return nil }
return id
}
}
private struct CommandGeneratorOverlay: View {
let state: CommandGeneratorState
@Environment(\.locale) private var locale
var body: some View {
VStack(spacing: Metrics.Space.s) {
symbol
Text(title).font(.headline)
if let detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.l)
.termindTransientOverlaySurface()
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var symbol: some View {
switch state {
case .readingDraft, .generating:
ProgressView().controlSize(.small)
case .inserted:
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.font(.title2)
.foregroundStyle(.orange)
case .idle:
EmptyView()
}
}
private var title: String {
switch state {
case .idle: ""
case .readingDraft, .generating:
AppLocalization.resolve(AppStrings.Workspace.generatingCommand, locale: locale)
case .inserted:
AppLocalization.resolve(AppStrings.Workspace.commandInserted, locale: locale)
case .failed:
AppLocalization.resolve(AppStrings.Workspace.commandGenerationFailed, locale: locale)
}
}
private var detail: String? {
switch state {
case .idle: nil
case .readingDraft, .generating:
AppLocalization.resolve(AppStrings.Workspace.thisMayTakeAMoment, locale: locale)
case .inserted:
AppLocalization.resolve(AppStrings.Workspace.pressReturnToRun, locale: locale)
case .failed(let message): message
}
}
}
/// Recursive layout for `SplitNode`.
private struct SplitNodeView: View {
let node: SplitNode
let workspace: Workspace
let isActive: Bool
let omittedPaneID: Pane.ID?
var body: some View {
switch node {
case .leaf(let pane):
// Identified, so the panes that survive a reflow keep their state
// instead of being torn down and rebuilt as the tree reshapes.
PaneView(pane: pane, workspace: workspace, isActive: isActive)
.id(pane.id)
.opacity(omittedPaneID == pane.id ? 0 : 1)
.allowsHitTesting(omittedPaneID != pane.id)
.accessibilityHidden(omittedPaneID == pane.id)
case .split(let branch):
SplitBranchView(
branch: branch,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
}
}
}
/// One division, laid out from its own stored fraction.
///
/// Deliberately not `HSplitView`/`VSplitView`. Those own the divider position,
/// which cost us two things: a drop previewed as "the right half" arrived at
/// whatever width AppKit picked, and resizing the workspace handed the new space to
/// one child instead of both. Sizing from `branch.fraction` on every layout pass
/// makes the first exact and the second proportional for free — there is no
/// resize handler here because there is nothing to handle.
private struct SplitBranchView: View {
let branch: SplitNode.Branch
let workspace: Workspace
let isActive: Bool
let omittedPaneID: Pane.ID?
/// The fraction previewed while the pointer is down.
///
/// Keeping this local matters for both correctness and responsiveness:
/// publishing `Workspace.layout` for every mouse event rebuilds the whole
/// pane tree, and measuring a moving divider in its own coordinate space
/// makes the gesture's translation move underneath the pointer. The drag
/// therefore uses global coordinates, previews locally, and commits once.
@State private var draggedFraction: Double?
var body: some View {
GeometryReader { proxy in
let total = length(of: proxy.size)
let lengths = childLengths(in: total)
switch branch.axis {
case .horizontal:
HStack(spacing: 0) {
child(branch.first)
.frame(width: lengths.first)
divider(total: total, isCollapsed: lengths.divider == 0)
child(branch.second)
.frame(width: lengths.second)
}
case .vertical:
VStack(spacing: 0) {
child(branch.first)
.frame(height: lengths.first)
divider(total: total, isCollapsed: lengths.divider == 0)
child(branch.second)
.frame(height: lengths.second)
}
}
}
}
private func child(_ node: SplitNode) -> some View {
SplitNodeView(
node: node,
workspace: workspace,
isActive: isActive,
omittedPaneID: omittedPaneID
)
.clipped()
}
// MARK: - Divider
/// A hairline to look at inside a wider band to grab: one point of visible
/// line is not a pointer target. Negative padding keeps the full grab band
/// centred over both panes while only the hairline consumes layout space.
private func divider(total: CGFloat, isCollapsed: Bool) -> some View {
let grabInset = (Metrics.Split.dividerHitLength - Metrics.Split.dividerThickness) / 2
return ZStack {
Color.clear.contentShape(.rect)
Rectangle().fill(.separator).frame(
width: branch.axis == .horizontal ? Metrics.Split.dividerThickness : nil,
height: branch.axis == .vertical ? Metrics.Split.dividerThickness : nil
)
}
.frame(
width: branch.axis == .horizontal
? (isCollapsed ? 0 : Metrics.Split.dividerHitLength) : nil,
height: branch.axis == .vertical
? (isCollapsed ? 0 : Metrics.Split.dividerHitLength) : nil
)
.padding(.horizontal, branch.axis == .horizontal && !isCollapsed ? -grabInset : 0)
.padding(.vertical, branch.axis == .vertical && !isCollapsed ? -grabInset : 0)
.opacity(isCollapsed ? 0 : 1)
.allowsHitTesting(!isCollapsed)
.pointerStyle(branch.axis == .horizontal ? .columnResize : .rowResize)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
let moved =
branch.axis == .horizontal
? value.translation.width
: value.translation.height
let usable = usableLength(in: total)
guard usable > 0 else { return }
draggedFraction = clamp(branch.fraction + moved / usable, in: total)
}
.onEnded { value in
let moved =
branch.axis == .horizontal
? value.translation.width
: value.translation.height
let usable = usableLength(in: total)
guard usable > 0 else {
draggedFraction = nil
return
}
let final = clamp(branch.fraction + moved / usable, in: total)
draggedFraction = nil
guard final != branch.fraction else { return }
workspace.setFraction(final, forBranch: branch.id)
}
)
}
// MARK: - Measurement
private func length(of size: CGSize) -> CGFloat {
branch.axis == .horizontal ? size.width : size.height
}
private func usableLength(in total: CGFloat) -> CGFloat {
max(0, total - Metrics.Split.dividerThickness)
}
private func clampedFirstLength(in total: CGFloat) -> CGFloat {
usableLength(in: total) * clamp(draggedFraction ?? branch.fraction, in: total)
}
private func childLengths(in total: CGFloat) -> (
first: CGFloat, divider: CGFloat, second: CGFloat
) {
if subtreeDisappearsWhenOmitted(branch.first) {
return (0, 0, total)
}
if subtreeDisappearsWhenOmitted(branch.second) {
return (total, 0, 0)
}
let first = clampedFirstLength(in: total)
let second = max(0, total - first - Metrics.Split.dividerThickness)
return (first, Metrics.Split.dividerThickness, second)
}
private func subtreeDisappearsWhenOmitted(_ node: SplitNode) -> Bool {
guard let omittedPaneID, node.pane(omittedPaneID) != nil else { return false }
return node.removing(omittedPaneID) == nil
}
/// Keeps both sides above `minPaneLength` — but only while there is room for
/// that. Below it the clamp inverts and pins the split at the middle, which
/// is the least surprising thing a pane too small for either rule can do.
private func clamp(_ fraction: Double, in total: CGFloat) -> Double {
let usable = usableLength(in: total)
guard usable > 0 else { return 0.5 }
let floor = Metrics.Split.minPaneLength / usable
guard floor * 2 <= 1 else { return 0.5 }
return min(max(fraction, floor), 1 - floor)
}
}
/// One pane: a header naming it, its content, and the focus affordance — the
/// first and last of which only exist while the workspace is split.
///
/// Every pane is a drop target, not just the empty ones. Dragging a workspace onto the
/// edge of a live terminal splits along that edge and folds the dragged panes in
/// on that side, so "put these two side by side" is one move rather than
/// Split-then-drag. Dropping on an empty pane still replaces it outright —
/// there is nothing there to sit beside.
private struct PaneView: View {
let pane: Pane
let workspace: Workspace
let isActive: Bool
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isHovering = false
@State private var isRenaming = false
@State private var draftTitle = ""
@State private var localFileDropRequest: LocalFileDropRequest?
@State private var sftpRemoteDropRequest: SFTPRemoteDropRequest?
@State private var nativeDropKind: NativeDropKind?
@FocusState private var titleFieldFocused: Bool
private var isFocused: Bool { workspace.focusedPaneID == pane.id }
private var showsHoverControls: Bool {
#if DEBUG
if ScreenshotDemoConfiguration.active?.scene == .workspace, isFocused {
return true
}
#endif
return isHovering
}
var body: some View {
VStack(spacing: 0) {
if workspace.isSplit {
header
if pane.kind == .empty { Divider() }
}
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
if workspace.isSplit {
Rectangle()
.strokeBorder(isFocused ? Color.accentColor : .clear, lineWidth: 1)
.allowsHitTesting(false)
}
}
// Keep the native receiver mounted above terminal/table content. It
// reads the live drag and displayed workspace without waiting for a
// SwiftUI render, and passes ordinary pointer events through.
.overlay { layoutDropTarget }
.overlay { paneFileDropDecoration }
// This must be the outermost pane overlay. Both Ghostty and SwiftUI's
// macOS Table install native AppKit views above their own SwiftUI
// modifiers, so a child-level upload target never enters the drag
// responder chain.
.overlay { paneFileDropTarget }
.animation(
Motion.dropTarget(reduceMotion: reduceMotion),
value: nativeDropKind
)
.contentShape(.rect)
.onHover { isHovering = $0 }
.onTapGesture {
window.dragging = nil
workspace.focus(pane.id)
}
}
private var layoutDropTarget: some View {
let controller = PaneLayoutDropController(window: window, workspace: workspace, pane: pane)
return WorkspaceLayoutDropTarget(
draggedID: { controller.draggedID },
preview: { point, size in controller.preview(at: point, in: size) },
perform: { point, size in controller.perform(at: point, in: size) }
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var paneFileDropTarget: some View {
switch pane.content {
case .sshSession:
NativeDropTarget(
targetedKind: $nativeDropKind,
acceptedKinds: [.files, .sftpItems, .terminalPath],
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
case .sftpChannel:
NativeDropTarget(
targetedKind: $nativeDropKind,
acceptedKinds: [.files, .sftpItems],
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
case .telnetSession, .serialSession:
NativeDropTarget(
targetedKind: $nativeDropKind,
acceptedKinds: [.terminalPath],
isEnabled: isActive && window.dragging == nil,
receive: receivePaneDrop
)
case .empty:
EmptyView()
}
}
@ViewBuilder
private var paneFileDropDecoration: some View {
if nativeDropKind != nil, let presentation = paneDropPresentation {
DropTargetOverlay(presentation: presentation)
}
}
private var paneDropPresentation: DropTargetPresentation? {
// Path insertion uses the native copy cursor, not the upload overlay.
guard nativeDropKind != .terminalPath else { return nil }
switch pane.content {
case .sshSession(let sshSession):
if let problem = TerminalUploadAvailability.problem(
isConnected: sshSession.status == .connected,
hasTransferConfiguration: sshSession.transferSnapshot != nil,
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
workingDirectory: sshSession.workingDirectory
) {
return DropTargetPresentation(
title: String(localized: AppStrings.SFTP.uploadUnavailable),
detail: problem,
systemImage: "exclamationmark.triangle.fill",
tone: .unavailable
)
}
let hostName = app.hostLibrary.host(sshSession.hostID)?.name ?? sshSession.title
return DropTargetPresentation(
title: nativeDropKind == .sftpItems
? String(localized: AppStrings.Workspace.copyTo(hostName))
: String(localized: AppStrings.Workspace.uploadTo(hostName)),
detail: sshSession.displayPath(
RemotePath.normalized(sshSession.workingDirectory ?? "/")
),
systemImage: nativeDropKind == .sftpItems
? "doc.on.doc.fill" : "arrow.up.doc.fill"
)
case .sftpChannel(let sftpChannel):
guard sftpChannel.status == .connected else {
return DropTargetPresentation(
title: String(localized: AppStrings.SFTP.uploadUnavailable),
detail: String(localized: AppStrings.Workspace.theSftpChannelIsNotConnected),
systemImage: "exclamationmark.triangle.fill",
tone: .unavailable
)
}
let hostName =
app.hostLibrary.host(sftpChannel.hostID)?.name
?? String(localized: AppStrings.SFTP.sftp)
return DropTargetPresentation(
title: nativeDropKind == .sftpItems
? String(localized: AppStrings.Workspace.copyTo(hostName))
: String(localized: AppStrings.Workspace.uploadTo(hostName)),
detail: sftpChannel.displayPath(sftpChannel.path),
systemImage: nativeDropKind == .sftpItems
? "doc.on.doc.fill" : "arrow.up.doc.fill"
)
case .telnetSession, .serialSession, .empty:
return nil
}
}
private func receivePaneDrop(_ payload: NativeDropPayload) -> Bool {
if let text = payload.terminalPath {
let owner: PersistentTerminalViewOwner
switch pane.content {
case .sshSession(let session): owner = session.terminalViewOwner
case .telnetSession(let session): owner = session.terminalViewOwner
case .serialSession(let session): owner = session.terminalViewOwner
case .sftpChannel, .empty: return false
}
workspace.focus(pane.id)
return owner.pasteDroppedPath(text)
}
if let data = payload.sftpItems {
switch pane.content {
case .sshSession, .sftpChannel:
sftpRemoteDropRequest = SFTPRemoteDropRequest(data: data)
return true
case .telnetSession, .serialSession, .empty:
return false
}
}
guard !payload.fileURLs.isEmpty else { return false }
localFileDropRequest = LocalFileDropRequest(urls: payload.fileURLs)
return true
}
@ViewBuilder
private var content: some View {
switch pane.content {
case .sshSession(let sshSession):
TerminalPlaceholderView(
sshSession: sshSession,
workspace: workspace,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused && !window.isQuickOpenPresented,
externalFileDrop: localFileDropRequest,
externalRemoteDrop: sftpRemoteDropRequest,
closePane: { window.closePane(pane.id) }
)
case .telnetSession(let telnetSession):
TelnetTerminalView(
session: telnetSession,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused && !window.isQuickOpenPresented,
closePane: { window.closePane(pane.id) }
)
case .sftpChannel(let sftpChannel):
SFTPBrowserView(
sftpChannel: sftpChannel,
isPaneFocused: isActive && isFocused,
focusPane: { workspace.focus(pane.id) },
externalFileDrop: localFileDropRequest,
externalRemoteDrop: sftpRemoteDropRequest,
closePane: { window.closePane(pane.id) }
)
case .serialSession(let serialSession):
SerialTerminalView(
session: serialSession,
isVisible: isActive,
requestsFocus: isActive && isFocused && !window.isToolbarFindFocused && !window.isQuickOpenPresented,
closePane: { window.closePane(pane.id) }
)
case .empty:
EmptyPaneView()
}
}
// MARK: - Header
/// The strip naming a pane, present only while the workspace is split — a
/// single-pane workspace is already named by its workspace, and a second copy of that
/// string sitting on the content says nothing.
///
/// Reserved space rather than an overlay. A label floating over a terminal
/// covers the first line of output, which is usually the line you opened the
/// pane to read.
///
/// The name is the drag handle, and nothing else in the row is: a terminal's
/// body belongs to selecting text, so making the whole pane draggable would
/// trade something you do constantly for something you do rarely.
@ViewBuilder
private var header: some View {
if presentsConnectionJourney {
headerContent.background {
ConnectionJourneyBackground()
}
} else if let state = terminalState {
headerContent.background { TerminalBackground(state: state) }
} else if pane.kind == .sftpChannel {
headerContent.background(TermindColor.windowSurface)
} else {
headerContent.termindGlassBar()
}
}
private var terminalState: TerminalViewState? {
switch pane.content {
case .sshSession(let session): session.terminalState
case .telnetSession(let session): session.terminalState
case .serialSession(let session): session.terminalState
case .sftpChannel, .empty: nil
}
}
private var presentsConnectionJourney: Bool {
switch pane.content {
case .sshSession(let session):
session.connectionJourney.isPresented
case .telnetSession(let session):
session.connectionJourney.isPresented
case .sftpChannel(let session):
session.connectionJourney.isPresented
case .serialSession(let session):
session.connectionJourney.isPresented
case .empty:
false
}
}
private var headerContent: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: pane.symbol)
.font(.system(size: Metrics.Split.headerIconSize))
.foregroundStyle(.secondary)
if isRenaming {
TextField(
AppStrings.Workspace.paneName.swiftUIKey, text: $draftTitle.singleLineText, axis: .horizontal
).singleLinePaste().lineLimit(
1
)
.textFieldStyle(.plain)
.font(.system(size: 11, weight: .medium))
.focused($titleFieldFocused)
.onSubmit(commitRename)
.onExitCommand { isRenaming = false }
} else {
title
}
Spacer(minLength: Metrics.Space.xs)
broadcastToggle
// The close button appears only under the pointer, the same rule the
// workspace bar follows: identity is what you want while scanning, the
// close button only once you are aiming at something.
if showsHoverControls {
Button {
window.closePane(pane.id)
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.frame(width: 14, height: 14)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(Text(AppStrings.Workspace.closePane))
}
}
.padding(.horizontal, Metrics.Space.m)
.frame(height: Metrics.Split.headerHeight)
}
/// Broadcast: type here, and every shell in the Workspace hears it.
///
/// Only on a shell's header, and only while there is a second shell to reach
/// — a file browser is not a participant, and a switch with one wire on it
/// is a control for nothing. See `Workspace.canBroadcast`.
///
/// It follows the ×'s rule exactly — the pointer reveals it, on or off — so
/// a header you are not aiming at is only an icon and a name, and the row
/// stays quiet while you read the pane under it. The cost is real and
/// accepted: a live broadcast is invisible from across the window, and what
/// says so is the accent tint the moment you point at any header.
///
/// No `keyboardShortcut` here. `WorkspaceContentView.broadcastShortcut` owns ⌘B;
/// declared in both places AppKit picks one arbitrarily.
@ViewBuilder
private var broadcastToggle: some View {
if pane.kind == .sshSession, workspace.canBroadcast {
let isOn = workspace.broadcastsInput
if showsHoverControls {
Button {
withAnimation(Motion.workspaceSwitch) {
window.toggleBroadcastInput(in: workspace)
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 9, weight: .medium))
.frame(width: 14, height: 14)
.background(
isOn ? Color.accentColor.opacity(0.18) : .clear,
in: .rect(cornerRadius: Metrics.Radius.small)
)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(isOn ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary))
.help(Text(AppStrings.Workspace.broadcastInputToEverySshSessionInThisWorkspaceB))
.accessibilityLabel(Text(AppStrings.Workspace.broadcastInput))
.accessibilityAddTraits(isOn ? [.isSelected, .isButton] : .isButton)
}
}
}
/// The draggable half of the header.
///
/// The drag preview is spelled out rather than left to macOS, which would
/// otherwise snapshot the source view — the old grip icon flying around said
/// nothing about which pane was in the air. A name is what identifies a
/// pane, so a name is what follows the cursor.
@ViewBuilder
private var title: some View {
let label = Text(displayedTitle)
.font(.system(size: 11, weight: isFocused ? .semibold : .regular))
.foregroundStyle(isFocused ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.lineLimit(1)
.truncationMode(.middle)
.contentShape(.rect)
.onTapGesture(count: 2, perform: beginRename)
.accessibilityLabel(Text(pane.title(in: app.hostLibrary)))
if pane.isCombinable {
label
.pointerStyle(.grabIdle)
.help(Text(AppStrings.Workspace.dragToMoveThisPaneDoubleClickToRename))
.modifier(
WorkspaceLayoutDragSource(
window: window, payload: .pane(pane.id),
title: displayedTitle, symbol: pane.symbol,
onTearOff: { point in
app.mainWindows.movePane(pane.id, from: window, at: point)
}
))
} else if pane.kind.isRenameable {
label
.help(Text(AppStrings.Workspace.doubleClickToRename))
} else {
// An unfilled slot is the only thing that reaches here, and it is
// named after the state it is in — there is nothing to call it but
// Empty, and it stops existing the moment something fills it.
label
}
}
private func beginRename() {
guard pane.kind.isRenameable else { return }
draftTitle = displayedTitle
isRenaming = true
titleFieldFocused = true
}
private func commitRename() {
window.rename(pane: pane.id, to: draftTitle)
isRenaming = false
}
private var displayedTitle: String {
workspace.isSplit
? pane.sidebarTitle(in: app.hostLibrary)
: workspace.title(in: app.hostLibrary)
}
}
/// The landing page, and what a window with no workspaces falls back to.
///
/// Deliberately a pane rather than an overlay, so closing the last workspace does not
/// leave a window with no content at all.
///
/// This is also where search lives. The sidebar is a navigator — a fixed list of
/// destinations — so putting a field there would have meant a control that
/// filters something two columns away. Here the field and its results are the
/// same view, and one input covers both jobs a launcher has: find a host you
/// saved, or type `user@host` for one you did not.
struct WelcomeView: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.locale) private var locale
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
@State private var query = ""
@State private var hasSubmitted = false
@State private var actionError: String?
@State private var welcomeMessage = " "
@FocusState private var isSearchFocused: Bool
private var welcomeGreetings: [String] {
[
String(localized: AppStrings.Workspace.helloIMTermind),
String(localized: AppStrings.Workspace.commandWithEaseStayInControl),
String(localized: AppStrings.Workspace.sshAndSftpWithAnAssistantAtHand),
]
}
private var welcomePrompts: [String] {
[
String(localized: AppStrings.Workspace.whatWouldYouLikeToDoConfirmation),
String(localized: AppStrings.Workspace.whatDoYouFeelLikeDoingConfirmation),
]
}
var body: some View {
ScrollView {
VStack(spacing: Metrics.Space.xxl) {
header
searchField
actions
results
}
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
.padding(.vertical, Metrics.Space.xxl)
.padding(.horizontal, Metrics.Space.xl)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
.onAppear { isSearchFocused = true }
.onChange(of: window.welcomeSearchFocusRequest) { _, _ in
isSearchFocused = true
}
}
private var header: some View {
GlyphixText(welcomeMessage)
.glyphixTextFont(.systemFont(ofSize: 30, weight: .semibold))
.glyphixTextColor(.labelColor)
.glyphixTextAnimationDisabled(accessibilityReduceMotion)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: 88, maxHeight: 88)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: welcomeMessage))
.task {
let greeting = welcomeGreetings.randomElement() ?? welcomeGreetings[0]
let prompt = welcomePrompts.randomElement() ?? welcomePrompts[0]
// Let GlyphixText mount before its first animated transition. Updating
// immediately can be folded into the initial render and skip the greeting.
if !accessibilityReduceMotion {
try? await Task.sleep(for: .milliseconds(50))
}
guard !Task.isCancelled else { return }
welcomeMessage = greeting
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
welcomeMessage = prompt
}
}
// MARK: - Search
private var searchField: some View {
TermindSearchField(
text: $query, prompt: AppStrings.Workspace.searchHostsOrTemplatesOrTypeSshUserHost,
density: .prominent, externalFocus: $isSearchFocused
)
.onSubmit(submit)
.onChange(of: query) { _, _ in
hasSubmitted = false
actionError = nil
}
.help(AppStrings.Workspace.supportedSSHOptions.swiftUIKey)
}
private var matches: [Host] {
query.isEmpty || isSSHCommand ? [] : app.hostLibrary.searchHosts(query)
}
private var matchingTemplates: [WorkspaceTemplate] {
guard !query.isEmpty, !isSSHCommand else { return [] }
return app.hostLibrary.sortedWorkspaceTemplates.filter { template in
template.name.localizedCaseInsensitiveContains(query)
|| template.layout.hostIDs.contains { hostID in
app.hostLibrary.host(hostID)?.name.localizedCaseInsensitiveContains(query) == true
}
}
}
private var isSSHCommand: Bool { QuickConnectParser.isCommand(query) }
private var resolution: Result<QuickConnectTarget, any Error>? {
guard matches.isEmpty, matchingTemplates.isEmpty,
case .connection(let input) = QuickConnectParser.parse(query, locale: locale)
else { return nil }
return Result { try app.resolveQuickConnect(input, locale: locale) }
}
private var adHoc: QuickConnectTarget? { try? resolution?.get() }
private var connectionError: String? {
if let actionError { return actionError }
switch QuickConnectParser.parse(query, locale: locale) {
case .invalid(let message): return message
case .incomplete(let message): return hasSubmitted ? message : nil
default: break
}
if case .failure(let error) = resolution { return error.localizedDescription }
return nil
}
private func submit() {
hasSubmitted = true
if let template = matchingTemplates.first {
window.openWorkspaceTemplate(template)
} else if let host = matches.first {
app.open(host, as: .preferredTerminal, in: window)
} else if let adHoc {
connect(adHoc)
return
} else {
return
}
query = ""
}
private func connect(_ target: QuickConnectTarget) {
app.openQuickConnect(target, in: window)
query = ""
}
private func save(_ target: QuickConnectTarget) {
Task {
do {
let host = try await app.saveQuickConnect(target)
query = host.name
} catch HostAccessError.hostLimit {
} catch {
actionError = error.localizedDescription
}
}
}
// MARK: - Actions
private var actions: some View {
WelcomeGlassGroup(spacing: Metrics.Space.l) {
ViewThatFits(in: .horizontal) {
HStack(spacing: Metrics.Space.l) {
actionButtons
}
VStack(spacing: Metrics.Space.s) {
actionButtons
}
}
}
}
@ViewBuilder
private var actionButtons: some View {
WelcomeActionButton(title: AppStrings.Common.connect, symbol: MenuSymbol.connect) {
app.connect(in: window)
}
WelcomeActionButton(title: AppStrings.Vault.batchStartup, symbol: MenuSymbol.batchStartup) {
app.batchStartup(in: window)
}
WelcomeActionButton(title: AppStrings.Assistant.newChat, symbol: MenuSymbol.newChat) {
window.openNewAssistantChat()
}
}
// MARK: - Results
/// Recents when the field is empty, matches when it is not. One list in one
/// place, so typing narrows what you were already looking at instead of
/// replacing it with a different-looking panel.
@ViewBuilder
private var results: some View {
if query.isEmpty {
let templates = app.hostLibrary.sortedWorkspaceTemplates
if !templates.isEmpty {
section(AppStrings.Workspace.workspaceTemplates) {
ForEach(templates) { template in
WelcomeWorkspaceTemplateRow(template: template)
}
}
}
let hosts = app.connectionHistory.recentLocalHosts(
in: app.hostLibrary, limit: max(0, 6 - templates.count))
if !hosts.isEmpty {
section(AppStrings.Workspace.recents) {
ForEach(hosts) { recent in
WelcomeHostRow(host: recent.host)
}
}
}
} else {
if !matchingTemplates.isEmpty {
section(AppStrings.Workspace.workspaceTemplates) {
ForEach(matchingTemplates) { template in
WelcomeWorkspaceTemplateRow(template: template)
}
}
}
if !matches.isEmpty {
section(AppStrings.Vault.hosts) {
ForEach(matches) { host in
WelcomeHostRow(host: host)
}
}
}
if let adHoc {
section(adHoc.isSaved ? AppStrings.Vault.hosts : AppStrings.Workspace.connectOnce) {
WelcomeQuickConnectRow(target: adHoc, connect: connect, save: save)
}
}
if let connectionError {
Text(connectionError)
.foregroundStyle(.secondary)
} else if adHoc == nil, !isSSHCommand, matchingTemplates.isEmpty, matches.isEmpty {
Text(AppStrings.Workspace.noHostsOrWorkspaceTemplatesMatch(query))
.foregroundStyle(.secondary)
}
}
}
private func section<Content: View>(
_ title: LocalizedStringResource,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
WelcomeGlassGroup(spacing: Metrics.Space.m) {
VStack(spacing: Metrics.Space.m) {
content()
}
}
}
}
}
/// Liquid Glass renders neighbouring controls as one responsive family on
/// macOS 26. Earlier systems keep the same layout while each child uses the
/// native material fallback supplied by `termindGlassSurface`.
private struct WelcomeGlassGroup<Content: View>: View {
let spacing: CGFloat
@ViewBuilder let content: Content
@ViewBuilder
var body: some View {
if #available(macOS 26.0, *) {
GlassEffectContainer(spacing: spacing) {
content
}
} else {
content
}
}
}
private struct WelcomeActionButton: View {
let title: LocalizedStringResource
let symbol: String
let action: () -> Void
var body: some View {
Button(action: action) {
Label(title, systemImage: symbol)
.font(.callout.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.s)
.contentShape(.capsule)
}
.buttonStyle(.plain)
.termindGlassCapsule(isInteractive: true)
}
}
private struct WelcomeLeadingIcon: View {
let symbol: String
var tint: Color = .accentColor
var body: some View {
RoundedRectangle(cornerRadius: Metrics.Welcome.leadingIconSize * 0.25)
.fill(tint.opacity(0.16))
.frame(
width: Metrics.Welcome.leadingIconSize,
height: Metrics.Welcome.leadingIconSize
)
.overlay {
Image(systemName: symbol)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(tint)
}
.accessibilityHidden(true)
}
}
private struct WelcomeWorkspaceTemplateRow: View {
let template: WorkspaceTemplate
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.controlActiveState) private var controlActiveState
@State private var isRenaming = false
@State private var renameTitle = ""
@ViewBuilder
var body: some View {
content
.termindGlassSurface(
cornerRadius: Metrics.Radius.medium,
isInteractive: !isRenaming
)
.contextMenu {
Button(AppStrings.Common.rename.swiftUIKey, systemImage: MenuSymbol.rename) { beginRename() }
Divider()
DestructiveMenuButton(AppStrings.Common.delete) {
if !app.hostLibrary.deleteWorkspaceTemplate(template.id) {
window.workspaceTemplateNotice = app.hostLibrary.storageError
}
}
}
}
@ViewBuilder
private var content: some View {
if isRenaming {
row
} else {
Button {
window.openWorkspaceTemplate(template)
} label: {
row
}
.buttonStyle(.plain)
}
}
private var row: some View {
HStack(spacing: Metrics.Space.m) {
WelcomeLeadingIcon(symbol: MenuSymbol.workspace)
if isRenaming {
SidebarInlineRenameField(
text: $renameTitle,
prompt: String(localized: AppStrings.Workspace.workspaceName),
isSelected: false,
controlActiveState: controlActiveState,
onSubmit: commitRename,
onCancel: cancelRename
).singleLinePaste()
.frame(height: 18)
} else {
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
Text(template.name)
.lineLimit(1)
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: Metrics.Space.m)
Text(AppStrings.Workspace.panes(template.layout.paneCount))
.font(.caption)
.foregroundStyle(.tertiary)
}
.font(.callout)
.padding(.horizontal, Metrics.Space.l)
.frame(
maxWidth: .infinity,
minHeight: Metrics.Welcome.rowHeight,
maxHeight: Metrics.Welcome.rowHeight,
alignment: .leading
)
.contentShape(.rect)
}
private func beginRename() {
renameTitle = template.name
isRenaming = true
}
private func commitRename() {
guard isRenaming else { return }
isRenaming = false
if !app.hostLibrary.renameWorkspaceTemplate(template.id, to: renameTitle) {
window.workspaceTemplateNotice = app.hostLibrary.storageError
}
}
private func cancelRename() {
isRenaming = false
renameTitle = ""
}
private var summary: String {
template.layout.panes.map { pane in
switch pane {
case .ssh(let hostID):
"\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · SSH"
case .mosh(let hostID):
"\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Mosh"
case .eternalTerminal(let hostID):
"\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Eternal Terminal"
case .telnet(let hostID):
"\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · Telnet"
case .sftp(let hostID):
"\(app.hostLibrary.host(hostID)?.name ?? String(localized: AppStrings.Workspace.unavailableHost)) · SFTP"
case .empty:
String(localized: AppStrings.Workspace.emptyPane)
}
}.joined(separator: ", ")
}
}
/// A host offered on the Welcome page. Click connects — the page exists to get
/// you onto a machine, and anything else is a right-click away.
private struct WelcomeHostRow: View {
let host: Host
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var confirmsHistoryClear = false
@State private var isMenuPresented = false
var body: some View {
Button {
app.open(host, as: .preferredTerminal, in: window)
} label: {
HStack(spacing: Metrics.Space.m) {
WelcomeHostIcon(
host: host,
status: app.hostLibrary.status(of: host.id)
)
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
Text(host.name)
.lineLimit(1)
// Resolved through the store, so a quick-connect row that
// matched a saved identity shows the login it will actually use.
Text(app.hostLibrary.preferredTerminalLoginAddress(for: host))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: Metrics.Space.m)
trailing
}
.font(.callout)
.padding(.horizontal, Metrics.Space.l)
.frame(
maxWidth: .infinity,
minHeight: Metrics.Welcome.rowHeight,
maxHeight: Metrics.Welcome.rowHeight,
alignment: .leading
)
.contentShape(.rect)
}
.buttonStyle(.plain)
.termindGlassSurface(cornerRadius: Metrics.Radius.medium, isInteractive: true)
.overlay {
// Build a native menu per click. SwiftUI's live context menu can shrink
// its popup window when the greeting updates during menu tracking.
NativeRowContextMenu(entries: { contextMenu }, isPresented: $isMenuPresented)
}
.overlay {
if isMenuPresented {
RoundedRectangle(cornerRadius: Metrics.Radius.medium)
.strokeBorder(Color.accentColor, lineWidth: 2)
.allowsHitTesting(false)
}
}
.confirmationDialog(
AppStrings.Workspace.clearRecentConnectionsConfirmation, isPresented: $confirmsHistoryClear,
titleVisibility: .visible
) {
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
app.connectionHistory.clear(for: host.id)
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
} message: {
Text(AppStrings.Workspace.clearHostHistoryWarning)
}
}
private var contextMenu: [NativeMenuEntry] {
HostMenuEntries.entries(for: host, app: app, window: window, includesDuplicate: true)
+ [
.separator,
NativeMenuEntry(
title: AppStrings.Common.delete, symbol: MenuSymbol.delete,
) {
if DeletionContentCategory.recordedContent.requiresConfirmation(for: .clearCollection) {
confirmsHistoryClear = true
} else {
app.connectionHistory.clear(for: host.id)
}
},
]
}
@ViewBuilder
private var trailing: some View {
if let date = app.lastConnectedLocallyAt(for: host.id) {
Text(date, format: .relative(presentation: .numeric))
.foregroundStyle(.tertiary)
}
}
}
private struct WelcomeHostIcon: View {
let host: Host
let status: ConnectionStatus
var body: some View {
HostConnectionIcon(
host: host, size: Metrics.Welcome.leadingIconSize, status: status,
badgeStyle: .welcome(
background: TermindColor.windowSurface, border: TermindColor.separator.opacity(0.8)
)
)
}
}
/// A resolved command preview. Only the explicit actions open or save it.
private struct WelcomeQuickConnectRow: View {
let target: QuickConnectTarget
var connect: (QuickConnectTarget) -> Void
var save: (QuickConnectTarget) -> Void
var body: some View {
HStack(spacing: Metrics.Space.m) {
WelcomeLeadingIcon(symbol: MenuSymbol.connect)
VStack(alignment: .leading, spacing: 2) {
Text(target.loginAddress)
.font(.callout.monospaced())
.lineLimit(1)
.help(target.loginAddress)
if target.route.hosts.count > 1 {
Text(target.routeSummary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.help(target.routeSummary)
}
Text(target.isSaved ? AppStrings.Workspace.saved : AppStrings.Workspace.notSaved)
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer(minLength: Metrics.Space.m)
Button(AppStrings.Common.connect.swiftUIKey) { connect(target) }
if !target.isSaved {
Button(AppStrings.Common.save.swiftUIKey) { save(target) }
.buttonStyle(.borderedProminent)
}
}
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.s)
.frame(maxWidth: .infinity, minHeight: Metrics.Welcome.rowHeight, alignment: .leading)
.termindGlassSurface(cornerRadius: Metrics.Radius.medium)
}
}