Files
Termind/TFM/Features/Workspaces/WorkspaceNavigatorView.swift
T
Kaede Akino 80f08b089f refactor: share transfer presentation and polish workspace actions
Centralize transfer state and presentation rules, extract the macOS transfer center, and preserve remote home metadata for compact iOS transfer paths.

Correct the shared Close Others action and extend transfer regression coverage.
2026-09-26 12:05:26 +08:00

686 lines
27 KiB
Swift

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) {
Image(systemName: workspace.symbol)
.foregroundStyle(
SidebarSelectionStyle.iconForeground(
isSelected: isSelected,
controlActiveState: controlActiveState
)
)
.frame(width: Metrics.Sidebar.rowIcon)
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.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] = []
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
)
)
if namedTitle != nil {
entries.append(
NativeMenuEntry(
title: localized(AppStrings.Workspace.resetName), 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(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()
}
}