import AppKit import SwiftUI import TermindApplication import UniformTypeIdentifiers /// The native source list for Vault and Workspaces. /// Workspace layout drops are accepted only inside 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 workspaceDropTarget: WorkspaceSidebarDropTarget? @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 = "" nonisolated private static let coordinateSpace = "termind.workspaceNavigator" 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: "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) .coordinateSpace(.named(Self.coordinateSpace)) .overlay { workspaceLayoutDropTarget } } // `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 // complete Workspace section while an internal layout drag is active. .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 } .onChange(of: window.dragging) { if window.dragging?.isLayoutPayload != true { workspaceDropTarget = nil } } } private var settingsSection: some View { Group { Text("Settings") .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 32) .padding( .horizontal, Metrics.Sidebar.navigationRowInset + Metrics.Sidebar.rowHorizontalMargin ) .termindSidebarSectionLabel() SidebarNavigatorRow( title: "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("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.identities.count case .snippets: app.hostLibrary.snippets.count case .knownHosts: app.knownHosts.entries.count } } // MARK: - Workspaces private var workspaceSection: some View { Group { navigatorSectionHeader("Workspaces", isExpanded: $workspaceExpanded) .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.coordinateSpace)) } action: { workspaceHeaderFrame = $0 } if workspaceExpanded { if window.workspaces.isEmpty { emptyRow("No Workspaces") } ForEach(Array(window.workspaces.enumerated()), id: \.element.id) { index, workspace in 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: .named(Self.coordinateSpace)) } action: { workspaceFrames[workspace.id] = $0 } .overlay(alignment: .top) { if workspaceDropTarget?.visibleBoundaryIndex == index { workspaceInsertionLine } } .overlay(alignment: .bottom) { if index == window.workspaces.indices.last, workspaceDropTarget?.visibleBoundaryIndex == window.workspaces.count { workspaceInsertionLine } } } // 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: "Batch Startup", symbol: "wind") { workspaceExpanded = true app.batchStartup(in: window) } .overlay(alignment: .top) { if window.workspaces.isEmpty, workspaceDropTarget != nil { workspaceInsertionLine } } .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.coordinateSpace)) } action: { batchStartupFrame = $0 } } } } private var draggedWorkspaceID: Workspace.ID? { guard case .workspace(let id)? = window.dragging else { return nil } return id } 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) } } @ViewBuilder private var workspaceLayoutDropTarget: some View { if window.dragging?.isLayoutPayload == true, !workspaceDropBounds.isNull, !workspaceDropBounds.isEmpty { GeometryReader { proxy in WorkspaceNavigatorDropReceiver( onLocation: { point in guard window.dragging?.isLayoutPayload == true else { workspaceDropTarget = nil return false } workspaceExpanded = true workspaceDropTarget = dropTarget(at: point) return workspaceDropTarget != nil }, onExit: { workspaceDropTarget = nil }, onDrop: { point in guard window.dragging?.isLayoutPayload == true, let target = dropTarget(at: point) else { workspaceDropTarget = nil return false } workspaceDropTarget = nil switch window.dragging { case .pane(let id): window.detachPane(id, at: target.insertionIndex) case .workspace(let id): window.move(id, to: target.insertionIndex) case .assistantConversation, nil: return false } window.dragging = nil return true } ) .frame(width: proxy.size.width, height: proxy.size.height) } .allowsHitTesting(true) } } private func dropTarget(at location: CGPoint) -> WorkspaceSidebarDropTarget? { let visibleRows = window.workspaces.compactMap { workspace -> (Workspace.ID, CGRect)? in guard let frame = workspaceFrames[workspace.id] else { return nil } return (workspace.id, frame) } let draggedRowIndex = draggedWorkspaceID.flatMap { draggedID in visibleRows.firstIndex(where: { $0.0 == draggedID }) } return WorkspaceSidebarDropGeometry.target( rowFrames: visibleRows.map(\.1), draggedRowIndex: draggedRowIndex, pointerY: location.y ) } private var workspaceInsertionLine: some View { Rectangle() .fill(Color.accentColor) .frame(height: 2) .padding(.horizontal, 8) .allowsHitTesting(false) } // MARK: - Shared rows and editing private func navigatorRow( title: LocalizedStringKey, 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: LocalizedStringKey, isExpanded: Binding ) -> some View { SidebarSectionHeader(title: title, isExpanded: isExpanded) } private func emptyRow(_ title: LocalizedStringKey) -> 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 } renamingWorkspaceID = workspace.id workspaceRenameTitle = workspace.title(in: app.hostLibrary, locale: locale) } 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 = "" } } 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 @ViewBuilder var body: some View { if isRenaming { row } else { row.onDrag { window.dragging = .workspace(workspace.id) return NSItemProvider(object: workspace.id.uuidString as NSString) } } } private var row: some View { HStack(spacing: Metrics.Space.s) { Image(systemName: workspace.symbol) .foregroundStyle( SidebarSelectionStyle.foreground( isSelected: isSelected, controlActiveState: controlActiveState, unselected: AnyShapeStyle(.secondary) ) ) .frame(width: Metrics.Sidebar.rowIcon) if isRenaming { SidebarInlineRenameField( text: $renameTitle, prompt: String(localized: "Workspace Name"), isSelected: isSelected, controlActiveState: controlActiveState, onSubmit: onCommitRename, onCancel: onCancelRename ) .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) } .simultaneousGesture(TapGesture(count: 2).onEnded(onBeginRename)) .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("Close Workspace — ⌥-click to close the others")) } else if workspace.status != .disconnected || workspace.hasUnreadOutput { StatusDot(status: workspace.status, hasUnread: workspace.hasUnreadOutput, size: 6) .frame(width: 14) } else { Color.clear.frame(width: 14, height: 14) } } private var contextMenu: [NativeMenuEntry] { var entries: [NativeMenuEntry] = [] if workspace.isRenameable { entries.append( NativeMenuEntry( title: localized("Rename"), symbol: MenuSymbol.rename, action: onBeginRename ) ) if namedTitle != nil { entries.append( NativeMenuEntry( title: localized("Reset Name"), symbol: MenuSymbol.resetName ) { window.rename(workspace.id, to: "") } ) } entries.append(.separator) } // 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("Separate Panes"), symbol: MenuSymbol.separatePanes ) { window.separatePanes(in: workspace.id) } ) entries.append(.separator) } if workspace.isCombinable { if workspace.isSplit { entries.append( NativeMenuEntry( title: localized("Duplicate in New Workspace"), symbol: MenuSymbol.duplicate ) { act { window.duplicateSelectedInNewWorkspace() } } ) } else { entries.append( NativeMenuEntry( title: localized("Duplicate"), symbol: MenuSymbol.duplicate, children: [ NativeMenuEntry( title: localized("in Current Workspace"), symbol: MenuSymbol.currentWorkspace ) { act { window.duplicateSelected(axis: .horizontal) } }, NativeMenuEntry( title: localized("in New Workspace"), symbol: MenuSymbol.newWorkspace ) { act { window.duplicateSelectedInNewWorkspace() } }, ] ) ) } entries.append(.separator) entries.append( NativeMenuEntry(title: localized("Split Right"), symbol: MenuSymbol.splitRight) { act { window.splitSelected(axis: .horizontal) } } ) entries.append( NativeMenuEntry(title: localized("Split Down"), symbol: MenuSymbol.splitDown) { act { window.splitSelected(axis: .vertical) } } ) entries.append(.separator) } // Close Other 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("Close Workspace"), symbol: MenuSymbol.closeWorkspace ) { window.close(workspace.id) } ) entries.append( NativeMenuEntry(title: localized("Close Other Workspaces")) { window.closeOthers(than: workspace.id) } ) entries.append( NativeMenuEntry(title: localized("Close All Workspaces")) { window.closeAll() } ) return entries } private var namedTitle: String? { workspace.isSplit ? workspace.customTitle : workspace.focusedPane.customTitle } private func localized(_ key: String.LocalizationValue) -> String { AppLocalization.string(key, locale: locale) } private func act(_ action: () -> Void) { window.select(workspace.id) action() } } private struct WorkspaceNavigatorDropReceiver: NSViewRepresentable { let onLocation: (CGPoint) -> Bool let onExit: () -> Void let onDrop: (CGPoint) -> Bool func makeNSView(context _: Context) -> WorkspaceNavigatorDropReceivingView { WorkspaceNavigatorDropReceivingView() } func updateNSView(_ view: WorkspaceNavigatorDropReceivingView, context _: Context) { view.onLocation = onLocation view.onExit = onExit view.onDrop = onDrop } } @MainActor private final class WorkspaceNavigatorDropReceivingView: NSView { var onLocation: (CGPoint) -> Bool = { _ in false } var onExit: () -> Void = {} var onDrop: (CGPoint) -> Bool = { _ in false } override var isFlipped: Bool { true } override init(frame frameRect: NSRect) { super.init(frame: frameRect) registerForDraggedTypes([.string]) } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError() } override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { update(sender) ? .move : [] } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { update(sender) ? .move : [] } override func draggingExited(_: (any NSDraggingInfo)?) { onExit() } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { let accepted = onDrop(location(of: sender)) onExit() return accepted } override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { onExit() super.concludeDragOperation(sender) } private func update(_ sender: any NSDraggingInfo) -> Bool { guard sender.draggingPasteboard.availableType(from: [.string]) != nil else { onExit() return false } return onLocation(location(of: sender)) } private func location(of sender: any NSDraggingInfo) -> CGPoint { convert(sender.draggingLocation, from: nil) } }