Files
Termind/TFM/Features/Workspaces/WorkspaceNavigatorView.swift
T

704 lines
28 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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<Bool>
) -> 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 {
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()
}
}