Files
Termind/TFM/App/RootView.swift
T

546 lines
21 KiB
Swift

@preconcurrency import AppKit
import SwiftUI
import TermindApplication
import TermindPresentation
/// One window with one native Navigator column and one persistent detail deck.
///
/// Vault pages, dynamic Workspaces, and Assistant conversations are destinations
/// in the Navigator rather than a second horizontal workspace system. Workspace native
/// views stay mounted while another destination is in front, preserving terminal
/// grids, scrollback, and SFTP state.
struct RootView: View {
@Environment(AppModel.self) private var app
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.locale) private var locale
private let requestID: UUID?
@State private var window: WindowModel
@State private var layoutWidth = Metrics.Window.defaultWidth
@State private var automaticQuickOpenReady = false
@AppStorage(DesktopSettingsKey.statusBarExpanded) private var isStatusBarExpanded = true
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var showsRemoteWindowTitle =
DesktopSettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
init(window: WindowModel = WindowModel(), requestID: UUID? = nil) {
self.requestID = requestID
_window = State(initialValue: window)
}
var body: some View {
@Bindable var window = window
@Bindable var presentations = window.presentations
NavigationSplitView(columnVisibility: sidebarVisibility) {
WindowSidebarView()
.background {
// Modern split views supply the system sidebar material.
// An additional glass surface changes its tint and contrast.
if #unavailable(macOS 26.0) {
LegacySidebarMaterialBackground()
.ignoresSafeArea()
}
}
.navigationSplitViewColumnWidth(
min: Metrics.Sidebar.min,
ideal: min(Metrics.Sidebar.ideal, sidebarMaxWidth),
max: sidebarMaxWidth
)
} detail: {
detail
// Floating over the pane rather than docked under it, so switching
// to a Workspace no longer resizes the content area — and so the
// bar reads as belonging to the session it describes.
.overlay(alignment: .bottomTrailing) {
if case .workspace = window.selectedDestination {
StatusBarView()
.padding(Metrics.StatusBar.floatingInset)
}
}
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.detailMinHeight,
maxHeight: .infinity
)
.navigationSplitViewColumnWidth(
min: Metrics.Window.detailMin,
ideal: Metrics.Chat.contentMaxWidth
)
}
.navigationSplitViewStyle(.balanced)
.environment(\.singleLinePasteAction, { SingleLineFieldEditing.paste() })
.background {
MainWindowRegistration(coordinator: app.mainWindows, model: window, requestID: requestID)
WindowToolbarSearchHost()
WindowActivityReader(
windowModel: window,
showsRemoteTitle: showsRemoteWindowTitle
) {
app.onboarding.windowBecameActive(window)
considerOnboarding()
}
}
.overlay { ExternalAuthWindowPresenter() }
.frame(
minWidth: Metrics.Window.minWidth(showsSidebar: window.showsSidebar),
maxWidth: .infinity,
minHeight: Metrics.Window.minHeight,
maxHeight: .infinity
)
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
layoutWidth = $0
}
.toolbarBackground(.hidden, for: .windowToolbar)
.navigationTitle(
WindowTitleCatalog.standard(hosts: app.hostLibrary)
.title(in: window, locale: locale)
)
.toolbar(removing: window.showsCompactMetricsDetail ? .title : nil)
// The window's only toolbar. Creation on the left, one Find on the
// right, both following the content in front rather than the sidebar —
// see `WindowToolbarLeadingActions` and `WindowModel.findKind`.
.toolbar {
ToolbarItemGroup(placement: .navigation) {
WindowToolbarLeadingActions()
}
ToolbarItem(placement: .principal) {
MetricsDetailPagePicker()
}
ToolbarItem(placement: .primaryAction) {
WindowToolbarSearchSlot()
}
}
.sheet(
item: $presentations.hostChooser,
onDismiss: {
let ids = window.pendingMetricsHostIDs
window.pendingMetricsHostIDs = []
guard !ids.isEmpty else { return }
window.metricsHostID = nil
app.serverMetrics.add(ids.compactMap(app.hostLibrary.host))
}
) { purpose in
HostChooserView(purpose: purpose)
.environment(app)
.environment(window)
}
.sheet(item: $presentations.workspaceTemplateName) { presentation in
WorkspaceTemplateNameSheet(presentation: presentation)
.environment(window)
}
.overlay {
if presentations.isQuickOpenPresented {
QuickOpenOverlay {
presentations.isQuickOpenPresented = false
}
.transition(
reduceMotion
? .opacity
: .opacity.combined(with: .scale(scale: 0.985))
)
}
}
.overlay(alignment: onboardingAlignment) {
if let onboardingPresentation {
OnboardingCoachMark(
presentation: onboardingPresentation,
dismiss: { app.onboarding.dismissCurrent() },
performAction: performOnboardingAction
)
.padding(onboardingPadding)
.zIndex(90)
}
}
.animation(
Motion.panel(reduceMotion: reduceMotion),
value: app.onboarding.presentation
)
.animation(Motion.panel(reduceMotion: reduceMotion), value: presentations.route?.id)
.alert(
"Conversation Operation Failed",
isPresented: Binding(
get: { window.assistantOperationError != nil },
set: { if !$0 { window.assistantOperationError = nil } }
)
) {
Button("OK") { window.assistantOperationError = nil }
} message: {
Text(window.assistantOperationError ?? "")
}
.alert(
"Workspace Template",
isPresented: Binding(
get: { window.workspaceTemplateNotice != nil },
set: { if !$0 { window.workspaceTemplateNotice = nil } }
)
) {
Button("OK") { window.workspaceTemplateNotice = nil }
} message: {
Text(window.workspaceTemplateNotice ?? "")
}
.dismissalConfirmationDialog("Close this window?", shouldPresent: !window.workspaces.isEmpty) {
Button("Cancel", role: .cancel) {}
.keyboardShortcut(.defaultAction)
Button("Close Window", role: .destructive) {}
} message: {
Text("This window still has open Workspaces. Closing it will close them and disconnect their sessions.")
}
// No ancestor drop target: pane and Navigator targets must own internal
// layout drops without competing for the same text payload.
.environment(window)
.modifier(AppReviewPresenter(window: window))
.proPaywallPresenter(app: app, window: window)
.focusedSceneValue(\.window, window)
.onChange(of: window.selectedDestination) {
window.refreshSessionContext()
window.reconcileSidebarMode()
}
.onAppear { [weak app] in
guard let app else { return }
app.registerWindow(window)
window.refreshSessionContext()
window.reconcileSidebarMode()
app.reconcileProAccess()
app.onboarding.windowBecameActive(window)
considerOnboarding()
}
.modifier(ExternalConnectionURLPresenter(app: app, window: window))
.onChange(of: app.proAccess.status) { app.reconcileProAccess() }
.onChange(of: onboardingContext) { considerOnboarding() }
.onChange(of: app.onboarding.revision) { considerOnboarding() }
.task {
try? await Task.sleep(for: .milliseconds(800))
guard !Task.isCancelled else { return }
automaticQuickOpenReady = true
considerOnboarding()
}
}
private var onboardingPresentation: OnboardingPresentation? {
guard let presentation = app.onboarding.presentation,
presentation.windowID == ObjectIdentifier(window)
else { return nil }
return presentation
}
private var onboardingContext: OnboardingContext {
_ = app.onboarding.revision
return OnboardingContext(
canPresentCoachMark: window.presentations.route == nil
&& app.proAccess.status != .loading,
automaticQuickOpenReady: automaticQuickOpenReady,
statusBarPath: onboardingPathContext,
canGenerateCommand: window.focusedSSHSession?.canGenerateFromCurrentDraft == true
&& app.commandGenerator.isLocallyAvailable
)
}
private var onboardingPathContext: OnboardingPathContext? {
guard isStatusBarExpanded, let pane = window.selectedWorkspace?.focusedPane,
let hostID = pane.hostID
else { return nil }
switch pane.content {
case .sshSession(let session):
guard app.hostLibrary.host(hostID) != nil, session.status.isLive,
let path = session.workingDirectory, !path.isEmpty,
session.displayedWorkingDirectory != nil
else {
return nil
}
return OnboardingPathContext(direction: .sshToSFTP, hostID: hostID, path: path)
case .sftpChannel(let session):
guard app.hostLibrary.host(hostID) != nil, session.status == .connected,
!session.path.isEmpty, session.displayedPath != nil
else { return nil }
return OnboardingPathContext(
direction: .sftpToSSH, hostID: hostID, path: session.path
)
case .telnetSession, .serialSession, .empty:
return nil
}
}
private var onboardingAlignment: Alignment {
switch onboardingPresentation?.step {
case .quickOpen: .topTrailing
case .statusBarPath: .bottomTrailing
case .commandGenerator: .bottomLeading
case nil: .topTrailing
}
}
private var onboardingPadding: EdgeInsets {
switch onboardingPresentation?.step {
case .statusBarPath:
EdgeInsets(
top: Metrics.Space.xl, leading: Metrics.Space.xl,
bottom: Metrics.StatusBar.overlayClearance, trailing: Metrics.Space.xl
)
case .commandGenerator:
EdgeInsets(
top: Metrics.Space.xl, leading: Metrics.Space.xl,
bottom: Metrics.StatusBar.overlayClearance, trailing: Metrics.Space.xl
)
case .quickOpen, nil:
EdgeInsets(
top: Metrics.Space.xl, leading: Metrics.Space.xl,
bottom: Metrics.Space.xl, trailing: Metrics.Space.xl
)
}
}
private func considerOnboarding() {
app.onboarding.consider(window: window, context: onboardingContext)
}
private func performOnboardingAction() {
guard let presentation = onboardingPresentation else { return }
switch presentation.step {
case .quickOpen:
app.onboarding.complete(.quickOpen)
window.isQuickOpenPresented = true
case .statusBarPath:
guard let context = onboardingPathContext,
let host = app.hostLibrary.host(context.hostID)
else {
app.onboarding.dismissCurrent()
return
}
app.onboarding.complete(.statusBarPath)
switch context.direction {
case .sshToSFTP:
window.openSFTPChannel(
host: host, initialPath: context.path, placement: .currentWorkspace
)
case .sftpToSSH:
window.openSSHSession(
host: host, initialPath: context.path, placement: .currentWorkspace
)
}
case .commandGenerator:
app.onboarding.complete(.commandGenerator)
}
}
private var sidebarVisibility: Binding<NavigationSplitViewVisibility> {
Binding(
get: { window.showsSidebar ? .all : .detailOnly },
set: { window.showsSidebar = $0 != .detailOnly }
)
}
private var sidebarMaxWidth: CGFloat {
Metrics.Window.sidebarMaxWidth(for: layoutWidth)
}
private var vaultWorkspace: some View {
VaultWorkspaceView()
}
@ViewBuilder
private var detail: some View {
ZStack {
PersistentWorkspaceDeckLayout {
ForEach(window.workspaces) { workspace in
let isActive = workspace.id == window.displayedWorkspace?.id
WorkspaceContentView(workspace: workspace, isActive: isActive)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.accessibilityHidden(!isActive)
.disabled(!isActive)
.zIndex(isActive ? 1 : 0)
.persistentWorkspaceDeckValue(id: workspace.id, isActive: isActive)
}
}
.clipped()
switch window.selectedDestination {
case .vault:
vaultWorkspace
.background(TermindColor.windowSurface)
.zIndex(2)
case .assistant:
AssistantView()
.background(TermindColor.windowSurface)
.transition(.move(edge: .trailing).combined(with: .opacity))
.zIndex(2)
case .metrics:
ServerMetricsView()
.background(TermindColor.windowSurface)
.zIndex(2)
case .settings:
SettingsPageView()
.background(TermindColor.windowSurface)
.transition(.move(edge: .trailing).combined(with: .opacity))
.zIndex(2)
case .workspace:
if window.workspaces.isEmpty {
VaultView(pages: window.vaultPages)
}
}
}
.animation(
Motion.panel(reduceMotion: reduceMotion),
value: isAssistantFullScreen
)
.clipped()
}
private var isAssistantFullScreen: Bool {
window.selectedDestination == .assistant
}
}
private struct ExternalConnectionURLPresenter: ViewModifier {
let app: AppModel
let window: WindowModel
func body(content: Content) -> some View {
content
.onOpenURL { app.openExternalConnection($0, in: window) }
.alert(
"Open Telnet Connection?",
isPresented: Binding(
get: { window.pendingExternalConnection != nil },
set: { if !$0 { window.pendingExternalConnection = nil } }
)
) {
Button("Cancel", role: .cancel) { window.pendingExternalConnection = nil }
Button("Continue") { app.confirmExternalConnection(in: window) }
} message: {
Text("This link includes an advisory Telnet password. It will be used once and will not be saved.")
}
.alert(
"Connection Link Failed",
isPresented: Binding(
get: { window.externalConnectionError != nil },
set: { if !$0 { window.externalConnectionError = nil } }
)
) {
Button("OK") { window.externalConnectionError = nil }
} message: {
Text(window.externalConnectionError ?? "")
}
}
}
/// Owns window-scoped AppKit integration: key-window onboarding and the remote
/// title report. Keeping both on this persistent bridge avoids routing title
/// changes through whichever Workspace or pane SwiftUI currently renders.
private struct WindowActivityReader: NSViewRepresentable {
let windowModel: WindowModel
let showsRemoteTitle: Bool
let becameKey: () -> Void
func makeNSView(context _: Context) -> WindowActivityBridgeView {
WindowActivityBridgeView(
windowModel: windowModel, showsRemoteTitle: showsRemoteTitle,
becameKey: becameKey
)
}
func updateNSView(_ view: WindowActivityBridgeView, context _: Context) {
view.windowModel = windowModel
view.showsRemoteTitle = showsRemoteTitle
view.becameKey = becameKey
view.observeWindow()
view.reportCurrentRemoteTitle()
}
static func dismantleNSView(_ view: WindowActivityBridgeView, coordinator _: ()) {
if view.windowModel?.remoteWindowTitleSink === view {
view.windowModel?.remoteWindowTitleSink = nil
}
RemoteWindowTitleMenuCoordinator.shared.withdraw(token: view.titleReportToken)
}
}
private final class WindowActivityBridgeView: NSView, WindowRemoteTitleSink {
let titleReportToken = UUID()
weak var windowModel: WindowModel? {
didSet {
guard windowModel !== oldValue else { return }
if oldValue?.remoteWindowTitleSink === self {
oldValue?.remoteWindowTitleSink = nil
}
windowModel?.remoteWindowTitleSink = self
}
}
var showsRemoteTitle: Bool
var becameKey: () -> Void
private var observer: (any NSObjectProtocol)?
private weak var observedWindow: NSWindow?
private var pendingRemoteTitleReport: Task<Void, Never>?
init(
windowModel: WindowModel, showsRemoteTitle: Bool,
becameKey: @escaping () -> Void
) {
self.windowModel = windowModel
self.showsRemoteTitle = showsRemoteTitle
self.becameKey = becameKey
super.init(frame: .zero)
windowModel.remoteWindowTitleSink = self
}
@available(*, unavailable)
required init?(coder _: NSCoder) { nil }
deinit {
pendingRemoteTitleReport?.cancel()
if let observer { NotificationCenter.default.removeObserver(observer) }
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
observeWindow()
reportCurrentRemoteTitle()
}
func observeWindow() {
guard observedWindow !== window else { return }
if let observer { NotificationCenter.default.removeObserver(observer) }
observedWindow = window
guard let window else {
observer = nil
return
}
observer = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.becameKey()
self?.reportCurrentRemoteTitle()
}
}
if window.isKeyWindow { becameKey() }
}
private func reportRemoteTitle(_ title: String?) {
guard let observedWindow else { return }
RemoteWindowTitleMenuCoordinator.shared.report(
title: title, for: observedWindow, token: titleReportToken
)
}
func reportCurrentRemoteTitle() {
reportRemoteTitle(
showsRemoteTitle ? windowModel?.focusedSSHSession?.menuBarWindowTitle : nil
)
}
func windowRemoteTitleDidChange(_ title: String?) {
pendingRemoteTitleReport?.cancel()
pendingRemoteTitleReport = Task { @MainActor [weak self] in
await Task.yield()
guard !Task.isCancelled, let self else { return }
self.reportRemoteTitle(self.showsRemoteTitle ? title : nil)
}
}
}