import AppKit import SwiftUI import TermindApplication import TermindPresentation /// The native source list for Vault and Workspaces. /// Workspace layout drops clamp to insertion boundaries in the Workspace section. struct WorkspaceNavigatorView: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.locale) private var locale @AppStorage(DesktopSettingsKey.vaultSectionExpanded) private var vaultExpanded = true @AppStorage(DesktopSettingsKey.workspaceSectionExpanded) private var workspaceExpanded = true @State private var workspaceFrames: [Workspace.ID: CGRect] = [:] @State private var workspaceHeaderFrame = CGRect.zero @State private var batchStartupFrame = CGRect.zero @State private var renamingWorkspaceID: Workspace.ID? @State private var workspaceRenameTitle = "" var body: some View { VStack(spacing: 0) { List { // Applied once here rather than on each row: the rows are shared // with the ListViewKit-backed modes, which have no list of their // own to inset, and the one rendered edge everything lines up on is // `Metrics.Sidebar.rowHorizontalMargin` inside the row itself. Group { welcomeRow SidebarNavigatorRow( title: AppStrings.Navigation.metrics, symbol: "waveform.path.ecg", isSelected: window.selectedDestination == .metrics ) { window.dragging = nil window.selectedDestination = .metrics } vaultSection workspaceSection settingsSection } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } .listStyle(.plain) .contentMargins(.horizontal, 0, for: .scrollContent) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 0) .overlay { workspaceLayoutDropTarget } .modifier(WorkspaceNavigatorDragSession(window: window)) } // `List` is backed by AppKit. A destination attached to the SwiftUI // ancestor sits behind its native rows and only wins sporadically as a // pane crosses from Ghostty into the sidebar. Put one receiver above the // list. It stays registered before AppKit starts its nested drag loop; // the receiver reads the window's drag state for each native callback. .onChange(of: window.selectedDestination) { switch window.selectedDestination { case .vault: vaultExpanded = true case .assistant, .settings, .metrics: break case .workspace: workspaceExpanded = true } } .onChange(of: window.vaultDestination) { vaultExpanded = true } } private var settingsSection: some View { Group { Text(AppStrings.Navigation.settings) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 32) .padding( .horizontal, Metrics.Sidebar.navigationRowInset + Metrics.Sidebar.rowHorizontalMargin ) .termindSidebarSectionLabel() SidebarNavigatorRow( title: AppStrings.Navigation.settings, symbol: "gearshape", isSelected: window.selectedDestination == .settings ) { window.dragging = nil window.openSettings() } } } // MARK: - Vault private var welcomeRow: some View { navigatorRow( title: VaultPage.welcome.label, symbol: VaultPage.welcome.symbol, count: 0, isSelected: window.selectedDestination == .vault && window.vaultDestination.page == .welcome ) { window.dragging = nil window.openPage(.welcome) } } private var vaultSection: some View { Group { navigatorSectionHeader(AppStrings.Navigation.vault, isExpanded: $vaultExpanded) if vaultExpanded { ForEach(VaultPage.allCases.filter { $0 != .welcome }) { page in SidebarNavigatorRow( title: page.label, symbol: page.symbol, count: count(for: page), isSelected: window.selectedDestination == .vault && window.vaultDestination.page == page, // The page's own New verbs, so adding a host does not // require visiting Hosts first. menu: { window.creationMenu(for: page, locale: locale) } ) { window.dragging = nil vaultExpanded = true window.openPage(page) } } } } } private func count(for page: VaultPage) -> Int { switch page { case .welcome: 0 case .hosts: app.hostLibrary.hosts.count case .keychain: app.hostLibrary.keys.count + app.hostLibrary.reusableIdentities.count case .portForwarding: app.forwardings.rules.count case .snippets: app.hostLibrary.snippets.count case .knownHosts: app.knownHosts.entries.count } } // MARK: - Workspaces private var workspaceSection: some View { Group { navigatorSectionHeader(AppStrings.Navigation.workspaces, isExpanded: $workspaceExpanded) .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { workspaceHeaderFrame = $0 } if workspaceExpanded { if window.workspaces.isEmpty { emptyRow(AppStrings.Workspace.noWorkspaces) } ForEach(window.workspaces) { workspace in if renamingWorkspaceID == workspace.id { workspaceRow(workspace) } else if #available(macOS 26.0, *) { // Keep the source outside measurement so List can read // its drag trait and start a native row drag. workspaceRow(workspace) .draggable(workspace.id.uuidString) { workspaceDragPreview(workspace) } } else { workspaceRow(workspace) .onDrag { window.dragging = .workspace(workspace.id) return NSItemProvider(object: workspace.id.uuidString as NSString) } preview: { workspaceDragPreview(workspace) } } } // The same container as the destinations above it. Drawn by // hand it sat 8pt further left than every other row and never // lit up under the pointer. SidebarNavigatorRow(title: AppStrings.Vault.batchStartup, symbol: MenuSymbol.batchStartup) { workspaceExpanded = true app.batchStartup(in: window) } .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { batchStartupFrame = $0 } } } } private func workspaceRow(_ workspace: Workspace) -> some View { WorkspaceNavigatorRow( workspace: workspace, isSelected: window.displayedWorkspace?.id == workspace.id, isRenaming: renamingWorkspaceID == workspace.id, renameTitle: $workspaceRenameTitle, onBeginRename: { beginWorkspaceRename(workspace) }, onCommitRename: { commitWorkspaceRename(workspace) }, onCancelRename: cancelWorkspaceRename ) .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { workspaceFrames[workspace.id] = $0 } } private func workspaceDragPreview(_ workspace: Workspace) -> some View { ItemDragPreview(title: workspace.title(in: app.hostLibrary, locale: locale), symbol: workspace.symbol) } private var draggedLayoutID: UUID? { guard window.sidebarMode == .navigation else { return nil } switch window.dragging { case .pane(let id), .workspace(let id): return id case .assistantConversation, nil: return nil } } private var workspaceDropBounds: CGRect { guard workspaceExpanded else { return workspaceHeaderFrame } let frames = [workspaceHeaderFrame, batchStartupFrame] + window.workspaces.compactMap { workspaceFrames[$0.id] } return frames.filter { !$0.isEmpty }.reduce(.null) { $0.union($1) } } private var workspaceLayoutDropTarget: some View { GeometryReader { proxy in // Native List cells do not inherit a named SwiftUI coordinate // space across their hosting boundary. Measure in global space, // then convert both the pointer and preview at the native overlay. let origin = proxy.frame(in: .global).origin WorkspaceLayoutDropTarget( draggedID: { draggedLayoutID }, previewStyle: .insertion, preview: { point, _ in let location = CGPoint(x: point.x + origin.x, y: point.y + origin.y) guard draggedLayoutID != nil, let proposal = dropProposal(at: location) else { return nil } if !workspaceExpanded { workspaceExpanded = true } return proposal.preview.offsetBy(dx: -origin.x, dy: -origin.y) }, perform: { point, _ in let location = CGPoint(x: point.x + origin.x, y: point.y + origin.y) guard draggedLayoutID != nil, let proposal = dropProposal(at: location) else { return false } if !workspaceExpanded { workspaceExpanded = true } switch window.dragging { case .pane(let id): window.detachPane(id, at: proposal.target.insertionIndex) case .workspace(let id): window.move(id, to: proposal.target.insertionIndex) case .assistantConversation, nil: return false } window.dragging = nil return true } ) .frame(width: proxy.size.width, height: proxy.size.height) } } private func dropProposal(at location: CGPoint) -> (target: WorkspaceSidebarDropTarget, preview: CGRect)? { guard !workspaceDropBounds.isNull, !workspaceDropBounds.isEmpty else { return nil } let visibleRows = window.workspaces.compactMap { workspace -> (Workspace.ID, CGRect)? in guard let frame = workspaceFrames[workspace.id], !frame.isEmpty else { return nil } return (workspace.id, frame) } let draggedRowIndex: Int? if case .workspace(let id)? = window.dragging { draggedRowIndex = visibleRows.firstIndex(where: { $0.0 == id }) } else { draggedRowIndex = nil } let target = WorkspaceSidebarDropGeometry.target( rowFrames: visibleRows.map(\.1), draggedRowIndex: draggedRowIndex, pointerY: location.y ) let frame: CGRect let boundaryY: CGFloat if let row = visibleRows.dropFirst(target.visibleBoundaryIndex).first { frame = row.1 boundaryY = frame.minY } else if let row = visibleRows.last { frame = row.1 boundaryY = frame.maxY } else if workspaceExpanded, !batchStartupFrame.isEmpty { frame = batchStartupFrame boundaryY = frame.minY } else { frame = workspaceHeaderFrame boundaryY = frame.maxY } guard !frame.isEmpty else { return nil } return ( target, CGRect(x: frame.minX + 8, y: boundaryY - 1, width: max(0, frame.width - 16), height: 2) ) } // MARK: - Shared rows and editing private func navigatorRow( title: LocalizedStringResource, symbol: String, count: Int, isSelected: Bool, action: @escaping () -> Void ) -> some View { SidebarNavigatorRow( title: title, symbol: symbol, count: count, isSelected: isSelected, action: action ) } private func navigatorSectionHeader( _ title: LocalizedStringResource, isExpanded: Binding ) -> some View { SidebarSectionHeader(title: title, isExpanded: isExpanded) } private func emptyRow(_ title: LocalizedStringResource) -> some View { Text(title) .font(.callout) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: Metrics.Sidebar.navigationRowHeight) .padding(.horizontal, Metrics.Sidebar.navigationRowInset) // The same breathing edge the rows around it carry, so "No // Workspaces" starts where a workspace name would. .padding(.horizontal, Metrics.Sidebar.rowHorizontalMargin) } private func beginWorkspaceRename(_ workspace: Workspace) { guard workspace.isRenameable else { return } workspaceRenameTitle = workspace.title(in: app.hostLibrary, locale: locale) renamingWorkspaceID = workspace.id } private func commitWorkspaceRename(_ workspace: Workspace) { guard renamingWorkspaceID == workspace.id else { return } let title = workspaceRenameTitle cancelWorkspaceRename() window.rename(workspace.id, to: title) } private func cancelWorkspaceRename() { renamingWorkspaceID = nil workspaceRenameTitle = "" } } private struct WorkspaceNavigatorDragSession: ViewModifier { let window: WindowModel @Environment(AppModel.self) private var app @State private var cancellation = WorkspaceDragCancellationTracker() func body(content: Content) -> some View { if #available(macOS 26.0, *) { content .dragConfiguration( DragConfiguration( operationsWithinApp: .init(allowMove: true), operationsOutsideApp: .init(allowCopy: false) ) ) .background { WorkspaceNavigatorDragAnimation { session in guard let value = session.draggingPasteboard.string(forType: .string), let id = UUID(uuidString: value), window.workspaces.contains(where: { $0.id == id }) else { return } // An outside release becomes a new window. Configure the // native session before AppKit starts any failed-drop animation. session.animatesToStartingPositionsOnCancelOrFail = false } } // Native List owns the session, so observe it here, not on a // row whose listener does not receive the native callbacks. .onDragSessionUpdated { session in switch session.phase { case .active: // The preview-capable draggable API has no session // item IDs. Its native pasteboard is ready at .active. // List also queries payloads after ordinary clicks, so // starting the preview must happen only in this phase. guard let value = NSPasteboard(name: .drag).string(forType: .string), let id = UUID(uuidString: value), window.workspaces.contains(where: { $0.id == id }) else { return } cancellation.begin() window.dragging = .workspace(id) case .ended(let operation): let wasCancelled = cancellation.end() if case .workspace(let id) = window.dragging, operation == .cancel || operation == .forbidden, WorkspaceLayoutDragCompletion.shouldTearOffAtCurrentPointer( outside: app.mainWindows.frame(for: window), cancelled: wasCancelled ) { app.mainWindows.moveWorkspace(id, from: window, at: NSEvent.mouseLocation) } if case .workspace = window.dragging { window.dragging = nil } default: break } } .onDisappear { cancellation.end() } } else { content } } } nonisolated struct WorkspaceSidebarDropTarget: Equatable, Sendable { let insertionIndex: Int let visibleBoundaryIndex: Int } nonisolated enum WorkspaceSidebarDropGeometry { static func target( rowFrames: [CGRect], draggedRowIndex: Int?, pointerY: CGFloat ) -> WorkspaceSidebarDropTarget { let visibleBoundaryIndex = insertionIndex(rowFrames: rowFrames, pointerY: pointerY) let insertionFrames = rowFrames.enumerated().compactMap { index, frame in index == draggedRowIndex ? nil : frame } return WorkspaceSidebarDropTarget( insertionIndex: insertionIndex(rowFrames: insertionFrames, pointerY: pointerY), visibleBoundaryIndex: visibleBoundaryIndex ) } static func insertionIndex(rowFrames: [CGRect], pointerY: CGFloat) -> Int { rowFrames.reduce(into: 0) { index, frame in if frame.midY < pointerY { index += 1 } } } } private struct WorkspaceNavigatorRow: View { let workspace: Workspace let isSelected: Bool let isRenaming: Bool @Binding var renameTitle: String let onBeginRename: () -> Void let onCommitRename: () -> Void let onCancelRename: () -> Void @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(\.controlActiveState) private var controlActiveState @Environment(\.locale) private var locale @State private var hovering = false var body: some View { row } private var row: some View { HStack(spacing: Metrics.Space.s) { ConnectionStatusIcon(status: workspace.status, hasUnread: workspace.hasUnreadOutput, style: .sidebar) { Image(systemName: workspace.symbol) .foregroundStyle( SidebarSelectionStyle.iconForeground( isSelected: isSelected, controlActiveState: controlActiveState ) ) .frame(width: Metrics.Sidebar.rowIcon) } .accessibilityValue( workspace.hasUnreadCommandCompletion ? String(localized: AppStrings.CommandNotifications.unread) : "") if isRenaming { SidebarInlineRenameField( text: $renameTitle, prompt: String(localized: AppStrings.Workspace.workspaceName), isSelected: isSelected, controlActiveState: controlActiveState, onSubmit: onCommitRename, onCancel: onCancelRename ).singleLinePaste() .frame(height: 18) } else { VStack(alignment: .leading, spacing: 1) { Text(workspace.title(in: app.hostLibrary, locale: locale)) .fontWeight(isSelected ? .semibold : .regular) .lineLimit(1) if let subtitle = workspace.subtitle(in: app.hostLibrary) { Text(subtitle) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) } } } Spacer(minLength: 0) trailingControl } .padding(.horizontal, Metrics.Sidebar.navigationRowInset) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: Metrics.Sidebar.workspaceRowHeight) .contentShape(.rect) .termindSidebarRow(isSelected: isSelected) { contextMenu } .onTapGesture { window.dragging = nil window.select(workspace.id) } .onHover { hovering = $0 } // Match Assistant's inline rename treatment: the row itself must not // draw a second keyboard-focus rectangle around the plain field. .focusable(false) .focusEffectDisabled() .help(Text(workspace.title(in: app.hostLibrary, locale: locale))) .accessibilityElement(children: .combine) .accessibilityLabel( Text(workspace.title(in: app.hostLibrary, locale: locale)) ) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) } @ViewBuilder private var trailingControl: some View { if hovering, !isRenaming { Button { if NSEvent.modifierFlags.contains(.option) { window.closeOthers(than: workspace.id) } else { window.close(workspace.id) } } label: { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) .frame(width: 14, height: 14) } .buttonStyle(.plain) .help(Text(AppStrings.Workspace.closeWorkspaceClickToCloseTheOthers)) } else if workspace.hasUnreadCommandCompletion { NotificationUnreadDot() .frame(width: 14) } else { Color.clear.frame(width: 14, height: 14) } } private var contextMenu: [NativeMenuEntry] { var entries: [NativeMenuEntry] = [] let hasUnread = workspace.hasUnreadCommandCompletion let readEntry = NativeMenuEntry( title: localized(hasUnread ? AppStrings.Notifications.markRead : AppStrings.Notifications.markUnread), symbol: hasUnread ? "circle" : "circle.fill" ) { if hasUnread { workspace.markCommandsRead() } else { workspace.markCommandsUnread() } } entries.append( NativeMenuEntry( title: localized( workspace.sourceTemplateID == nil ? AppStrings.Workspace.saveWorkspaceTemplate : AppStrings.Workspace.updateWorkspaceTemplate ), symbol: "square.and.arrow.down", isEnabled: workspace.workspaceTemplateLayout(in: app.hostLibrary) != nil ) { act { window.requestSaveWorkspaceTemplate(workspace.id) } } ) entries.append(.separator) if workspace.isRenameable { entries.append( NativeMenuEntry( title: localized(AppStrings.Common.rename), symbol: MenuSymbol.rename, action: onBeginRename ) ) entries.append(readEntry) if namedTitle != nil { entries.append( NativeMenuEntry( title: localized(AppStrings.Workspace.resetName), symbol: MenuSymbol.resetName ) { window.rename(workspace.id, to: "") } ) } entries.append(.separator) } else { entries.append(readEntry) } // Only a merged Workspace can be taken apart, and taking it apart is // the only way back — the last pane cannot be dragged out to the // Navigator, because a pane alone in its Workspace has nothing to // detach from. if workspace.isSplit { entries.append( NativeMenuEntry( title: localized(AppStrings.Workspace.separatePanes), symbol: MenuSymbol.separatePanes ) { window.separatePanes(in: workspace.id) } ) entries.append(.separator) } if workspace.isCombinable { if workspace.isSplit { entries.append( NativeMenuEntry( title: localized(AppStrings.Workspace.duplicateInNewWorkspace), symbol: MenuSymbol.duplicate ) { act { window.duplicateSelectedInNewWorkspace() } } ) } else { entries.append( NativeMenuEntry( title: localized(AppStrings.Common.duplicate), symbol: MenuSymbol.duplicate, children: [ NativeMenuEntry( title: localized(AppStrings.Workspace.inCurrentWorkspace), symbol: MenuSymbol.currentWorkspace ) { act { window.duplicateSelected(axis: .horizontal) } }, NativeMenuEntry( title: localized(AppStrings.Workspace.inNewWorkspace), symbol: MenuSymbol.newWorkspace ) { act { window.duplicateSelectedInNewWorkspace() } }, ] ) ) } entries.append(.separator) entries.append( NativeMenuEntry(title: localized(AppStrings.Workspace.splitRight), symbol: MenuSymbol.splitRight) { act { window.splitSelected(axis: .horizontal) } } ) entries.append( NativeMenuEntry(title: localized(AppStrings.Workspace.splitDown), symbol: MenuSymbol.splitDown) { act { window.splitSelected(axis: .vertical) } } ) entries.append(.separator) } entries.append( NativeMenuEntry( title: localized(AppStrings.Workspace.moveWorkspaceToNewWindow), symbol: MenuSymbol.moveToNewWindow ) { app.mainWindows.moveWorkspace(workspace.id, from: window) } ) entries.append(.separator) // Close Others and Close All are the same verb as the item above them, // so only the first of the run carries a glyph. entries.append( NativeMenuEntry( title: localized(AppStrings.Workspace.closeWorkspace), symbol: MenuSymbol.closeWorkspace ) { window.close(workspace.id) } ) entries.append( NativeMenuEntry(title: localized(AppStrings.Workspace.closeOtherWorkspaces)) { window.closeOthers(than: workspace.id) } ) entries.append( NativeMenuEntry(title: localized(AppStrings.Workspace.closeAllWorkspaces)) { window.closeAll() } ) return entries } private var namedTitle: String? { workspace.isSplit ? workspace.customTitle : workspace.focusedPane.customTitle } private func localized(_ key: LocalizedStringResource) -> String { AppLocalization.resolve(key, locale: locale) } private func act(_ action: () -> Void) { window.select(workspace.id) action() } }