510 lines
20 KiB
Swift
510 lines
20 KiB
Swift
// 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)
|
|
}
|
|
}
|