// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import SwiftUI import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import struct TermindDomain.Host /// What the capsule shrinks to. nonisolated enum StatusBarCollapsedGlyph: Hashable, Sendable { case connection(ConnectionStatus) case transfers } /// Whether the floating capsule appears, and what it says when collapsed. /// /// Pure, and therefore testable: the same call answers both questions, so a /// capsule can never be on screen with nothing to shrink to, or shrink to /// something it is not showing. nonisolated enum StatusBarPresentation { /// nil when the capsule does not appear at all. static func collapsedGlyph( hasSessionContext: Bool, status: ConnectionStatus, hasTransfers: Bool, isEnabled: Bool = true ) -> StatusBarCollapsedGlyph? { guard isEnabled else { return nil } // The connection wins when there is one. A transfer is a thing that is // happening; the connection is the thing you are looking at, and the // dot's colour is the only state worth a glance while collapsed. if hasSessionContext { return .connection(status) } if hasTransfers { return .transfers } return nil } } /// The direction worth showing inside the active transfer progress ring. /// /// Only work that is actually moving bytes participates. Queued and historical /// transfers still contribute to the transfer center's existing counts and /// state, but they must not make a running upload or download look mixed. nonisolated enum StatusBarTransferPresentation { static let idleDirectionSymbol = "arrow.up.arrow.down" static func runningDirectionSymbol(for transfers: [SFTPTransfer]) -> String { let directions = Set( transfers.lazy .filter { $0.state == .running } .map(\.direction) ) if directions == [.upload] { return "arrow.up" } if directions == [.download] { return "arrow.down" } return "arrow.up.arrow.down" } } /// Focused-session context and app-wide transfers, as one floating capsule. /// /// It used to be a docked strip with a hairline above it, which read as a piece /// of window chrome bolted under the content. Floating, it belongs to the pane /// it describes and matches the other glass objects in the app — the mode rail, /// the composer, the sidebar's search. /// /// It hugs its content and sits in the trailing corner rather than spanning the /// width: a full-width bar over a terminal covers whole lines of output, and the /// prompt lives on the left. Anything else anchored to the bottom of a pane /// clears it by `Metrics.StatusBar.overlayClearance`. struct StatusBarView: View { @AppStorage(DesktopSettingsKey.showStatusBar) private var showsStatusBar = true @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.accessibilityReduceMotion) private var reduceMotion @AppStorage(DesktopSettingsKey.statusBarExpanded) private var isExpanded = true private var pane: Pane? { window.selectedWorkspace?.focusedPane } private var showsSessionContext: Bool { pane?.kind.showsStatusBar == true } private var hasTransfers: Bool { !app.sftpTransfers.sftpTransfers.isEmpty } private var sshSession: SSHSessionModel? { guard case .sshSession(let sshSession)? = pane?.content else { return nil } return sshSession } private var sftpChannel: SFTPChannelModel? { pane?.sftpChannel } private var serialSession: SerialSessionModel? { pane?.serialSession } /// One rule decides both whether the capsule appears at all and what it /// shrinks to, so the two can never disagree — see `StatusBarPresentation`. private var collapsedGlyph: StatusBarCollapsedGlyph? { StatusBarPresentation.collapsedGlyph( hasSessionContext: showsSessionContext, status: status, hasTransfers: hasTransfers, isEnabled: showsStatusBar ) } @ViewBuilder var body: some View { if let collapsedGlyph { capsule(collapsedGlyph) } } /// Expanded and collapsed share one chain from here down, so the two states /// cannot drift apart in radius, height or how they sit in the corner. /// /// Both are the same `.capsule` too. A capsule as wide as it is tall *is* a /// circle, so collapsing morphs the shape rather than swapping one /// container for another and cross-fading between them. private func capsule(_ glyph: StatusBarCollapsedGlyph) -> some View { Group { if isExpanded { expandedContent .padding(.horizontal, Metrics.Space.l) } else { collapsedContent(glyph) .frame(width: Metrics.StatusBar.height) } } .font(.caption) .foregroundStyle(.secondary) .labelStyle(.titleAndIcon) .lineLimit(1) .frame(height: Metrics.StatusBar.height) .termindGlassCapsule() // A ceiling on what the capsule is *offered*, not a width it takes. // The row inside has no `Spacer`, so it hugs its text; this only stops a // very long working directory from spanning the pane, and the trailing // alignment keeps the capsule pinned to the corner as it grows and // shrinks rather than drifting from the edge. .frame(maxWidth: Metrics.StatusBar.maxWidth, alignment: .trailing) .animation(Motion.panel(reduceMotion: reduceMotion), value: isExpanded) } private var expandedContent: some View { HStack(spacing: Metrics.Space.l) { if showsSessionContext { connection if let sshSession, sshSession.status.isLive { if let workingDirectory = sshSession.workingDirectory, let displayed = sshSession.displayedWorkingDirectory, host != nil { workingDirectoryButton(workingDirectory, displayed: displayed) } } if let sftpChannel, sftpChannel.status == .connected { if sftpChannel.isLoading || sftpChannel.canCancelOperation { sftpOperationStatus(sftpChannel) } else { if let displayed = sftpChannel.displayedPath { sftpDirectoryButton(sftpChannel.path, displayed: displayed) } } } } if hasTransfers { TransferCenterButton( coordinator: app.sftpTransfers, hosts: app.hostLibrary ) } collapseButton } } /// Inside the capsule rather than floating beside it, so it reads as part of /// the object it closes rather than as a stray control over the terminal. private var collapseButton: some View { Button { isExpanded = false } label: { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.tertiary) .frame(width: 12, height: Metrics.StatusBar.height) .contentShape(.rect) } .buttonStyle(.plain) .help(Text(AppStrings.Workspace.hideSessionDetails)) .accessibilityLabel(Text(AppStrings.Workspace.hideSessionDetails)) } /// The whole circle is the target. Its content still carries the one fact /// worth keeping at a glance — whether the connection is live — which is /// the reason this is what the capsule shrinks to. private func collapsedContent(_ glyph: StatusBarCollapsedGlyph) -> some View { Button { isExpanded = true } label: { Group { switch glyph { case .connection(let status): Circle() .fill(status.tint) .frame( width: Metrics.Sidebar.statusDot, height: Metrics.Sidebar.statusDot ) case .transfers: TransferGlyph( summary: app.sftpTransfers.summary, transfers: app.sftpTransfers.sftpTransfers ) } } .frame( width: Metrics.StatusBar.height, height: Metrics.StatusBar.height ) .contentShape(.circle) } .buttonStyle(.plain) .help(Text(collapsedHelp(glyph))) .accessibilityLabel(Text(AppStrings.Workspace.showSessionDetails)) .accessibilityValue(Text(collapsedHelp(glyph))) } private func collapsedHelp(_ glyph: StatusBarCollapsedGlyph) -> String { switch glyph { case .connection(let status): host.map { "\(app.hostLibrary.loginAddress(for: $0)) — \(status.label)" } ?? status.label case .transfers: String(localized: AppStrings.SFTP.transfers) } } private var status: ConnectionStatus { sshSession?.status ?? sftpChannel?.status ?? serialSession?.status ?? .disconnected } @ViewBuilder private var connection: some View { HStack(spacing: Metrics.Space.s) { Circle() .fill(status.tint) .frame( width: Metrics.Sidebar.statusDot, height: Metrics.Sidebar.statusDot ) .accessibilityLabel(Text(status.label)) if let host { Text(host.name) Text(host.host).foregroundStyle(.tertiary) } else if let serialSession { Text(serialSession.title) Text(serialSession.subtitle).foregroundStyle(.tertiary) } else { Text(status.label) } } .help( Text( host.map { app.hostLibrary.loginAddress(for: $0) } ?? serialSession?.subtitle ?? status.label )) } private var host: Host? { pane?.hostID.flatMap { app.hostLibrary.host($0) } } private func workingDirectoryButton(_ path: String, displayed: String) -> some View { Button { app.onboarding.recordUse(of: .statusBarPath) openInSFTP(path, placement: .currentWorkspace) } label: { StatusBarPathLabel( symbol: "folder", text: displayed, destinationSymbol: "arrow.up.right" ) } .buttonStyle(.plain) .help(Text(AppStrings.SFTP.openDirectoryWithSFTPHelp)) .accessibilityLabel(Text(AppStrings.SFTP.openWithSftp)) .onDrag { TerminalPathDrag.provider(for: path) } .contextMenu { // The children read on from the parent — "Open with SFTP ▸ in New // Workspace" — and take the same pair of glyphs Duplicate's do. Menu(AppStrings.SFTP.openWithSftp.swiftUIKey, systemImage: MenuSymbol.sftp) { Button(AppStrings.Workspace.inCurrentWorkspace.swiftUIKey, systemImage: MenuSymbol.currentWorkspace) { app.onboarding.recordUse(of: .statusBarPath) openInSFTP(path, placement: .currentWorkspace) } Button(AppStrings.Workspace.inNewWorkspace.swiftUIKey, systemImage: MenuSymbol.newWorkspace) { app.onboarding.recordUse(of: .statusBarPath) openInSFTP(path, placement: .newWorkspace) } } Button(AppStrings.Workspace.copyPath.swiftUIKey, systemImage: MenuSymbol.copy) { app.onboarding.recordUse(of: .statusBarPath) copyToPasteboard(RemoteShellCommand.escapeArgument(path)) } } } private func sftpDirectoryButton(_ path: String, displayed: String) -> some View { let transport = host?.preferredTerminalProtocol ?? .ssh return Button { app.onboarding.recordUse(of: .statusBarPath) openInSSH(path, placement: .currentWorkspace) } label: { StatusBarPathLabel( symbol: transport.symbol, text: displayed, destinationSymbol: "arrow.up.right" ) } .buttonStyle(.plain) .disabled(host?.sshEnabled != true) .help(Text(AppStrings.Workspace.openDirectoryWithTransportHelp(transport.displayName))) .accessibilityLabel(Text(AppStrings.Connections.openWithTransport(transport.displayName))) .onDrag { TerminalPathDrag.provider(for: path) } .contextMenu { Menu( AppStrings.Connections.openWithTransport(transport.displayName).swiftUIKey, systemImage: transport.symbol ) { Button(AppStrings.Workspace.inCurrentWorkspace.swiftUIKey, systemImage: MenuSymbol.currentWorkspace) { app.onboarding.recordUse(of: .statusBarPath) openInSSH(path, placement: .currentWorkspace) } Button(AppStrings.Workspace.inNewWorkspace.swiftUIKey, systemImage: MenuSymbol.newWorkspace) { app.onboarding.recordUse(of: .statusBarPath) openInSSH(path, placement: .newWorkspace) } } Button(AppStrings.Workspace.copyPath.swiftUIKey, systemImage: MenuSymbol.copy) { app.onboarding.recordUse(of: .statusBarPath) copyToPasteboard(RemoteShellCommand.escapeArgument(path)) } } } private func sftpOperationStatus(_ session: SFTPChannelModel) -> some View { HStack(spacing: Metrics.Space.s) { ProgressView().controlSize(.mini) Text(session.hint.isEmpty ? String(localized: AppStrings.Workspace.working) : session.hint) .truncationMode(.middle) if session.canCancelOperation { Button { session.cancelOperation() } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) .help(AppStrings.Workspace.cancelOperation.swiftUIKey) .accessibilityLabel(AppStrings.Workspace.cancelOperation.swiftUIKey) } } } private func openInSFTP(_ path: String, placement: SessionOpenPlacement) { guard let host else { return } if let sshSession { Task { guard await sshSession.confirmDisplayedDirectory(path) else { return } window.openSFTPChannel(host: host, initialPath: path, placement: placement) } } else { window.openSFTPChannel(host: host, initialPath: path, placement: placement) } } private func openInSSH(_ path: String, placement: SessionOpenPlacement) { guard let host, host.sshEnabled, let transport = host.preferredTerminalProtocol else { return } window.openSSHSession(host: host, transport: transport, initialPath: path, placement: placement) } } private struct StatusBarPathLabel: View { let symbol: String let text: String let destinationSymbol: String @State private var isHovering = false var body: some View { HStack(spacing: Metrics.Space.xs) { Image(systemName: symbol) Text(text).truncationMode(.head) Image(systemName: destinationSymbol) .font(.system(size: 8, weight: .semibold)) .foregroundStyle(.tertiary) } .foregroundStyle(isHovering ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .padding(.horizontal, Metrics.Space.s) .padding(.vertical, Metrics.Space.xxs) .background( isHovering ? Color.accentColor.opacity(0.12) : .clear, in: .rect(cornerRadius: Metrics.Radius.small) ) .contentShape(.rect) .onHover { isHovering = $0 } .pointerStyle(.link) .animation(.easeOut(duration: 0.12), value: isHovering) } } // MARK: - Transfer Center /// The transfer state as one glyph: a progress ring while anything is running, /// a warning once something has failed, and the idle arrows otherwise. /// /// Its own view because the collapsed capsule shows it too, in the one case /// where there is no session to describe — a transfer running with no pane in /// front of it. struct TransferGlyph: View { static let size: CGFloat = 18 let summary: SFTPTransferSummary let transfers: [SFTPTransfer] var body: some View { Group { if summary.activeCount > 0 { ZStack { Circle().stroke(.primary.opacity(0.14), lineWidth: 2) if let aggregateProgress = summary.aggregateProgress { Circle() .trim(from: 0, to: aggregateProgress) .stroke( Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round) ) .rotationEffect(.degrees(-90)) } else { ProgressView().controlSize(.mini) } directionGlyph( StatusBarTransferPresentation.runningDirectionSymbol(for: transfers) ) } } else if summary.failedCount > 0 { Image(systemName: "exclamationmark.circle.fill") .resizable() .scaledToFit() .foregroundStyle(.orange) } else { directionGlyph(StatusBarTransferPresentation.idleDirectionSymbol) } } .frame(width: Self.size, height: Self.size) } private func directionGlyph(_ symbol: String) -> some View { Image(systemName: symbol) .font(.system(size: 8, weight: .bold)) } } private struct TransferCenterButton: View { let coordinator: SFTPTransferCoordinator let hosts: HostLibraryModel @State private var isPresented = false private var summaryValue: SFTPTransferSummary { coordinator.summary } var body: some View { Button { isPresented.toggle() } label: { HStack(spacing: Metrics.Space.xs) { TransferGlyph(summary: summaryValue, transfers: coordinator.sftpTransfers) if summaryValue.activeCount > 0 { Text(summaryValue.activeCount, format: .number) .monospacedDigit() } else if summaryValue.failedCount > 0 { Text(summaryValue.failedCount, format: .number) .monospacedDigit() } } .contentShape(.rect) } .buttonStyle(.plain) .termindInputAwareFocusEffect() .help(Text(summary)) .accessibilityLabel(Text(summary)) .popover(isPresented: $isPresented, arrowEdge: .bottom) { TransferCenterPopover( coordinator: coordinator, hosts: hosts, dismiss: { isPresented = false } ) } } private var summary: String { if summaryValue.activeCount > 0 { return summaryValue.activeCount == 1 ? String(localized: AppStrings.SFTP.oneActiveTransfer) : String(localized: AppStrings.SFTP.activeTransfers(summaryValue.activeCount)) } if summaryValue.failedCount > 0 { return summaryValue.failedCount == 1 ? String(localized: AppStrings.SFTP.oneFailedTransfer) : String(localized: AppStrings.SFTP.failedTransfers(summaryValue.failedCount)) } return String(localized: AppStrings.SFTP.transfers) } }