@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 { 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? 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) } } }