551 lines
21 KiB
Swift
551 lines
21 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
@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 {
|
|
@AppStorage(DesktopSettingsKey.showStatusBar) private var showsStatusBar = true
|
|
@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
|
|
|
|
init(window: WindowModel = WindowModel(), requestID: UUID? = nil) {
|
|
self.requestID = requestID
|
|
_window = State(initialValue: window)
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
}
|
|
|
|
@ViewBuilder private var content: some View {
|
|
@Bindable var window = window
|
|
@Bindable var presentations = window.presentations
|
|
|
|
NavigationSplitView(columnVisibility: sidebarVisibility) {
|
|
WindowSidebarView()
|
|
.onGeometryChange(for: CGFloat.self) {
|
|
$0.size.width
|
|
} action: { width in
|
|
app.mainWindows.recordSidebarWidth(width, for: window)
|
|
}
|
|
.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(window.sidebar.preferredWidth, 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, showsStatusBar {
|
|
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)
|
|
.onChange(of: window.visibleAssistantConversationID, initial: true) { app.refreshAssistantReadState() }
|
|
.id(window.sidebar.initialLayoutGeneration)
|
|
.environment(\.singleLinePasteAction, { SingleLineFieldEditing.paste() })
|
|
.background {
|
|
WorkspaceNumberShortcuts(window: window)
|
|
MainWindowRegistration(coordinator: app.mainWindows, model: window, requestID: requestID)
|
|
WindowToolbarSearchHost()
|
|
WindowActivityReader {
|
|
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
|
|
}
|
|
.onChange(of: window.showsSidebar) { _, _ in
|
|
app.mainWindows.recordSidebarPresentation(for: window)
|
|
}
|
|
.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) {
|
|
WindowToolbarTrailingActions()
|
|
}
|
|
}
|
|
.sheet(
|
|
item: $presentations.hostChooser,
|
|
onDismiss: {
|
|
let ids = window.pendingMetricsHostIDs
|
|
let editable = window.pendingMetricsEditableHostIDs
|
|
window.pendingMetricsHostIDs = nil
|
|
window.pendingMetricsEditableHostIDs = []
|
|
guard let ids else { return }
|
|
window.metricsHostID = nil
|
|
app.serverMetrics.replaceHosts(selectedIDs: ids, editableHostIDs: editable)
|
|
}
|
|
) { purpose in
|
|
HostChooserView(purpose: purpose)
|
|
.environment(app)
|
|
.environment(window)
|
|
.allowsApplicationTerminationWhenModal()
|
|
}
|
|
.sheet(item: $presentations.workspaceTemplateName) { presentation in
|
|
WorkspaceTemplateNameSheet(presentation: presentation)
|
|
.environment(window)
|
|
.allowsApplicationTerminationWhenModal()
|
|
}
|
|
.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(
|
|
AppStrings.Assistant.conversationOperationFailed,
|
|
isPresented: Binding(
|
|
get: { window.assistantOperationError != nil },
|
|
set: { if !$0 { window.assistantOperationError = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.assistantOperationError = nil }
|
|
} message: {
|
|
Text(window.assistantOperationError ?? "")
|
|
}
|
|
.alert(
|
|
AppStrings.Workspace.workspaceTemplate,
|
|
isPresented: Binding(
|
|
get: { window.workspaceTemplateNotice != nil },
|
|
set: { if !$0 { window.workspaceTemplateNotice = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.workspaceTemplateNotice = nil }
|
|
} message: {
|
|
Text(window.workspaceTemplateNotice ?? "")
|
|
}
|
|
.confirmationDialog(
|
|
AppStrings.Connections.clearConnectionHistoryConfirmation,
|
|
isPresented: $window.confirmsClearConnectionHistory, titleVisibility: .visible
|
|
) {
|
|
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
|
|
app.clearConnectionHistory()
|
|
window.historyOperationError = app.connectionHistory.storageError
|
|
}
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
|
|
} message: {
|
|
Text(AppStrings.Workspace.clearHistoryWarning)
|
|
}
|
|
.alert(
|
|
AppStrings.Common.operationFailed,
|
|
isPresented: Binding(
|
|
get: { window.historyOperationError != nil },
|
|
set: { if !$0 { window.historyOperationError = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.historyOperationError = nil }
|
|
} message: {
|
|
Text(window.historyOperationError ?? "")
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
|
|
app.connectionHistory.refreshMenuDate()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in
|
|
app.connectionHistory.refreshMenuDate()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
|
app.connectionHistory.refreshMenuDate()
|
|
}
|
|
// No ancestor drop target: pane and Navigator targets must own internal
|
|
// layout drops without competing for the same text payload.
|
|
.environment(window)
|
|
.modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications))
|
|
.alert(
|
|
AppStrings.LocalNetwork.wake,
|
|
isPresented: Binding(
|
|
get: { window.wakeNotice != nil }, set: { if !$0 { window.wakeNotice = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.wakeNotice = nil }
|
|
} message: {
|
|
Text(window.wakeNotice ?? "")
|
|
}
|
|
.alert(
|
|
AppStrings.Connections.connectionFailed,
|
|
isPresented: Binding(
|
|
get: { window.hostAccessError != nil }, set: { if !$0 { window.hostAccessError = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.hostAccessError = nil }
|
|
} message: {
|
|
Text(window.hostAccessError ?? "")
|
|
}
|
|
.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: 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 showsStatusBar, 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(isEnabled: showsStatusBar),
|
|
trailing: Metrics.Space.xl
|
|
)
|
|
case .commandGenerator:
|
|
EdgeInsets(
|
|
top: Metrics.Space.xl, leading: Metrics.Space.xl,
|
|
bottom: Metrics.StatusBar.overlayClearance(isEnabled: showsStatusBar),
|
|
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(
|
|
AppStrings.Workspace.openTelnetConnectionConfirmation,
|
|
isPresented: Binding(
|
|
get: { window.pendingExternalConnection != nil },
|
|
set: { if !$0 { window.pendingExternalConnection = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { window.pendingExternalConnection = nil }
|
|
Button(AppStrings.Common.continue.swiftUIKey) { app.confirmExternalConnection(in: window) }
|
|
} message: {
|
|
Text(AppStrings.Workspace.advisoryTelnetPassword)
|
|
}
|
|
.alert(
|
|
AppStrings.Workspace.connectionLinkFailed,
|
|
isPresented: Binding(
|
|
get: { window.externalConnectionError != nil },
|
|
set: { if !$0 { window.externalConnectionError = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey) { window.externalConnectionError = nil }
|
|
} message: {
|
|
Text(window.externalConnectionError ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Notifies onboarding when this window becomes active.
|
|
private struct WindowActivityReader: NSViewRepresentable {
|
|
let becameKey: () -> Void
|
|
|
|
func makeNSView(context _: Context) -> WindowActivityBridgeView {
|
|
WindowActivityBridgeView(becameKey: becameKey)
|
|
}
|
|
|
|
func updateNSView(_ view: WindowActivityBridgeView, context _: Context) {
|
|
view.becameKey = becameKey
|
|
view.observeWindow()
|
|
}
|
|
}
|
|
|
|
private final class WindowActivityBridgeView: NSView {
|
|
var becameKey: () -> Void
|
|
private var observer: (any NSObjectProtocol)?
|
|
private weak var observedWindow: NSWindow?
|
|
|
|
init(becameKey: @escaping () -> Void) {
|
|
self.becameKey = becameKey
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) { nil }
|
|
|
|
deinit {
|
|
if let observer { NotificationCenter.default.removeObserver(observer) }
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
observeWindow()
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
if window.isKeyWindow { becameKey() }
|
|
}
|
|
}
|