1409 lines
54 KiB
Swift
1409 lines
54 KiB
Swift
// 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)
|
||
}
|
||
}
|