1412 lines
50 KiB
Swift
1412 lines
50 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import ListViewKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
/// The window's one left-hand column. Its mode buttons share one glass capsule
|
|
/// with a neutral selection highlight, keeping the symbols above the material.
|
|
struct WindowSidebarView: View {
|
|
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@Environment(\.controlActiveState) private var controlActiveState
|
|
@FocusState private var focusedMode: WindowSidebarState.Mode?
|
|
@Namespace private var modeSelectionNamespace
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
modeSelector
|
|
.padding(.horizontal, Metrics.Space.m)
|
|
.padding(.vertical, Metrics.Space.s)
|
|
sidebarContent
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.onChange(of: focusedPaneID, initial: true) {
|
|
window.reconcileSidebarMode()
|
|
}
|
|
.onChange(of: window.selectedDestination, initial: true) {
|
|
window.reconcileSidebarMode()
|
|
}
|
|
}
|
|
|
|
private var focusedPaneID: Pane.ID? { window.selectedWorkspace?.focusedPaneID }
|
|
|
|
private var modeSelector: some View {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
ForEach(WindowSidebarState.Mode.allCases) { mode in
|
|
modeButton(mode)
|
|
}
|
|
}
|
|
.padding(Metrics.Space.xs)
|
|
.termindGlassCapsule()
|
|
.overlay {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
ForEach(WindowSidebarState.Mode.allCases) { mode in
|
|
Color.clear.frame(width: 36, height: 36)
|
|
.overlay(alignment: .topTrailing) {
|
|
NotificationCountBadge(unreadCount(for: mode)).offset(y: 3)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(Metrics.Space.xs)
|
|
.allowsHitTesting(false)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.animation(reduceMotion ? nil : Motion.panel, value: window.sidebarMode)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(AppStrings.Navigation.sidebar))
|
|
}
|
|
|
|
private func modeButton(_ mode: WindowSidebarState.Mode) -> some View {
|
|
let isSelected = window.sidebarMode == mode
|
|
let isEnabled = window.isSidebarModeEnabled(mode)
|
|
|
|
return Button {
|
|
guard isEnabled else { return }
|
|
window.activateSidebarMode(mode)
|
|
} label: {
|
|
modeIcon(mode, isSelected: isSelected, isEnabled: isEnabled)
|
|
.frame(maxWidth: .infinity)
|
|
.background {
|
|
if isSelected {
|
|
Capsule()
|
|
.fill(Color.primary.opacity(0.10))
|
|
.matchedGeometryEffect(id: "selection", in: modeSelectionNamespace)
|
|
}
|
|
}
|
|
.contentShape(.capsule)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focused($focusedMode, equals: mode)
|
|
.focusEffectDisabled()
|
|
.overlay {
|
|
if focusedMode == mode && controlActiveState == .key {
|
|
Capsule()
|
|
.strokeBorder(Color.primary.opacity(0.35), lineWidth: 2)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.disabled(!isEnabled)
|
|
.help(Text(mode.label))
|
|
.accessibilityLabel(Text(mode.label))
|
|
.accessibilityValue(
|
|
unreadCount(for: mode) > 0
|
|
? String(localized: AppStrings.Notifications.unread(unreadCount(for: mode))) : ""
|
|
)
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
|
|
private func modeIcon(
|
|
_ mode: WindowSidebarState.Mode,
|
|
isSelected: Bool,
|
|
isEnabled: Bool
|
|
) -> some View {
|
|
Image(systemName: mode.symbol)
|
|
.font(.system(size: 15, weight: isSelected ? .semibold : .medium))
|
|
.foregroundStyle(isEnabled ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
|
|
private func unreadCount(for mode: WindowSidebarState.Mode) -> Int {
|
|
switch mode {
|
|
case .navigation: window.unreadCompletionCount
|
|
case .assistant: app.unreadAssistantNotificationCount
|
|
case .snippets, .history: 0
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sidebarContent: some View {
|
|
switch window.sidebarMode {
|
|
case .navigation:
|
|
WorkspaceNavigatorView()
|
|
case .assistant:
|
|
AssistantPanelView()
|
|
case .snippets:
|
|
SnippetsSidebarView()
|
|
case .history:
|
|
HistorySidebarView()
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated struct SidebarListItem: Identifiable, Hashable, Sendable {
|
|
nonisolated enum SectionID: Hashable, Sendable {
|
|
case package(UUID)
|
|
case ungrouped
|
|
case results
|
|
|
|
var snippetExpansionID: String? {
|
|
switch self {
|
|
case .package(let id): "package:\(id.uuidString.lowercased())"
|
|
case .ungrouped: "ungrouped"
|
|
case .results: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated enum ID: Hashable, Sendable {
|
|
case section(SectionID)
|
|
case snippet(Snippet.ID)
|
|
case history(CommandHistoryEntry.ID)
|
|
case sftpHistory(SFTPHistoryEntry.ID)
|
|
case connection(ConnectionHistoryEntry.ID)
|
|
}
|
|
|
|
nonisolated enum Content: Hashable, Sendable {
|
|
/// Expansion deliberately does not live in this value. ListViewKit can
|
|
/// then keep the header row alive while the click inside it removes the
|
|
/// section's Snippet rows.
|
|
case section(SectionID, title: String, count: Int)
|
|
case snippet(Snippet)
|
|
case history(CommandHistoryEntry, isNaming: Bool)
|
|
case sftpHistory(SFTPHistoryEntry, displayedPath: String)
|
|
case connection(ConnectionHistoryEntry, Host, address: String)
|
|
}
|
|
|
|
let id: ID
|
|
let content: Content
|
|
|
|
static func section(_ id: SectionID, title: String, count: Int = 0) -> Self {
|
|
.init(id: .section(id), content: .section(id, title: title, count: count))
|
|
}
|
|
|
|
static func snippet(_ snippet: Snippet) -> Self {
|
|
.init(id: .snippet(snippet.id), content: .snippet(snippet))
|
|
}
|
|
|
|
static func history(_ entry: CommandHistoryEntry, isNaming: Bool) -> Self {
|
|
.init(id: .history(entry.id), content: .history(entry, isNaming: isNaming))
|
|
}
|
|
|
|
static func sftpHistory(
|
|
_ entry: SFTPHistoryEntry, displayedPath: String? = nil
|
|
) -> Self {
|
|
.init(
|
|
id: .sftpHistory(entry.id),
|
|
content: .sftpHistory(entry, displayedPath: displayedPath ?? entry.path)
|
|
)
|
|
}
|
|
|
|
static func connection(
|
|
_ entry: ConnectionHistoryEntry, host: Host, address: String
|
|
) -> Self {
|
|
.init(
|
|
id: .connection(entry.id),
|
|
content: .connection(entry, host, address: address)
|
|
)
|
|
}
|
|
|
|
/// One height for every content card, because they are one card with four
|
|
/// fillings — see `SidebarCard`. The exception is a command being named as
|
|
/// a snippet, where the row grows for the field rather than the field being
|
|
/// squeezed into a row built for two lines of text.
|
|
var height: CGFloat {
|
|
switch content {
|
|
case .section(let section, _, _):
|
|
section.snippetExpansionID == nil ? 32 : 34
|
|
case .snippet: Metrics.Sidebar.historyCardHeight
|
|
case .history(_, let isNaming):
|
|
isNaming
|
|
? Metrics.Sidebar.historyCardEditingHeight
|
|
: Metrics.Sidebar.historyCardHeight
|
|
case .sftpHistory, .connection: Metrics.Sidebar.historyCardHeight
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persistent presentation state belonging only to the compact Snippets shelf.
|
|
/// The main Snippets page continues to use `SnippetPackage.isExpanded`.
|
|
nonisolated struct SnippetSidebarExpansionState: Equatable, Sendable {
|
|
private(set) var collapsedSectionIDs: Set<String>
|
|
|
|
init(rawValue: String = "") {
|
|
collapsedSectionIDs = Set(rawValue.split(separator: "\n").map(String.init))
|
|
}
|
|
|
|
var rawValue: String {
|
|
collapsedSectionIDs.sorted().joined(separator: "\n")
|
|
}
|
|
|
|
func isExpanded(_ section: SidebarListItem.SectionID) -> Bool {
|
|
guard let id = section.snippetExpansionID else { return true }
|
|
return !collapsedSectionIDs.contains(id)
|
|
}
|
|
|
|
mutating func setExpanded(
|
|
_ expanded: Bool,
|
|
for section: SidebarListItem.SectionID
|
|
) {
|
|
guard let id = section.snippetExpansionID else { return }
|
|
if expanded {
|
|
collapsedSectionIDs.remove(id)
|
|
} else {
|
|
collapsedSectionIDs.insert(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated struct SnippetSidebarGroup: Sendable {
|
|
let id: SidebarListItem.SectionID
|
|
let title: String
|
|
let snippets: [Snippet]
|
|
}
|
|
|
|
/// Keeps filtering and folding rules testable without constructing the AppKit list.
|
|
nonisolated enum SnippetSidebarListBuilder {
|
|
static func groupedItems(
|
|
_ groups: [SnippetSidebarGroup],
|
|
expansion: SnippetSidebarExpansionState
|
|
) -> [SidebarListItem] {
|
|
var items: [SidebarListItem] = []
|
|
for group in groups where !group.snippets.isEmpty {
|
|
items.append(.section(group.id, title: group.title, count: group.snippets.count))
|
|
if expansion.isExpanded(group.id) {
|
|
items.append(contentsOf: group.snippets.map(SidebarListItem.snippet))
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
static func searchItems(
|
|
_ snippets: [Snippet], title: String
|
|
) -> [SidebarListItem] {
|
|
[.section(.results, title: title, count: snippets.count)]
|
|
+ snippets.map(SidebarListItem.snippet)
|
|
}
|
|
}
|
|
|
|
/// The heading above a group of cards.
|
|
///
|
|
/// Inset exactly like `SidebarSectionHeader` in the Navigator and the Assistant,
|
|
/// so every section label in the column starts on the same vertical line —
|
|
/// this one used to sit 2pt further right for no reason anyone could see except
|
|
/// by putting the two modes side by side.
|
|
private func sidebarSectionTitle(_ title: String) -> some View {
|
|
Text(title)
|
|
.termindSidebarSectionLabel()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(
|
|
.horizontal,
|
|
Metrics.Sidebar.navigationRowInset + Metrics.Sidebar.rowHorizontalMargin
|
|
)
|
|
.padding(.top, Metrics.Space.s)
|
|
}
|
|
|
|
/// A Snippet package heading with the same count, chevron and hit region as an
|
|
/// Assistant section. It owns the storage read so changing expansion never
|
|
/// changes the `SidebarListItem` representing this row.
|
|
private struct SnippetSidebarSectionHeaderRow: View {
|
|
let section: SidebarListItem.SectionID
|
|
let title: String
|
|
let count: Int
|
|
|
|
@AppStorage(DesktopSettingsKey.snippetsSidebarCollapsedSections)
|
|
private var collapsedSections = ""
|
|
|
|
private var isExpanded: Bool {
|
|
SnippetSidebarExpansionState(rawValue: collapsedSections).isExpanded(section)
|
|
}
|
|
|
|
private func toggleExpansion() {
|
|
var state = SnippetSidebarExpansionState(rawValue: collapsedSections)
|
|
state.setExpanded(!state.isExpanded(section), for: section)
|
|
collapsedSections = state.rawValue
|
|
}
|
|
|
|
var body: some View {
|
|
SidebarSectionHeader(
|
|
title: title, count: count, height: 34,
|
|
isExpanded: Binding(
|
|
get: { isExpanded },
|
|
set: { expanded in
|
|
if expanded != isExpanded { toggleExpansion() }
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct SidebarNativeList: NSViewRepresentable {
|
|
typealias NSViewType = SidebarListContainer
|
|
typealias List = ListView<SidebarListItem>
|
|
typealias Renderer = @MainActor (SidebarListItem) -> AnyView
|
|
|
|
let items: [SidebarListItem]
|
|
/// Whether adding and removing rows animates.
|
|
///
|
|
/// History says no. Its content reorders as a side effect of something the
|
|
/// user did elsewhere — opening a host moves it to the top of Recents — and
|
|
/// animating that reorder makes the list jump under a pointer that is aimed
|
|
/// at a row which has already moved.
|
|
var animatesStructuralChanges = true
|
|
/// The right-click menu for the space below the last row.
|
|
var blankAreaMenu: (() -> [NativeMenuEntry])?
|
|
let renderer: Renderer
|
|
|
|
@Environment(\.locale) private var locale
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
init(
|
|
items: [SidebarListItem],
|
|
animatesStructuralChanges: Bool = true,
|
|
blankAreaMenu: (() -> [NativeMenuEntry])? = nil,
|
|
@ViewBuilder renderer: @escaping @MainActor (SidebarListItem) -> some View
|
|
) {
|
|
self.items = items
|
|
self.animatesStructuralChanges = animatesStructuralChanges
|
|
self.blankAreaMenu = blankAreaMenu
|
|
self.renderer = { AnyView(renderer($0)) }
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
|
|
func makeNSView(context: Context) -> NSViewType {
|
|
SidebarListContainer(listView: context.coordinator.makeListView())
|
|
}
|
|
|
|
func updateNSView(_ container: NSViewType, context: Context) {
|
|
container.blankAreaMenu = blankAreaMenu
|
|
container.locale = locale
|
|
context.coordinator.update(
|
|
listView: container.listView,
|
|
items: items,
|
|
renderer: renderer,
|
|
locale: locale,
|
|
colorScheme: colorScheme,
|
|
animated: animatesStructuralChanges
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
final class Coordinator {
|
|
private let content = HostingListContent<SidebarListItem>()
|
|
|
|
func makeListView() -> List {
|
|
let listView = List()
|
|
listView.topInset = Metrics.Space.xs
|
|
listView.bottomInset = Metrics.Space.xs
|
|
listView.estimatedRowHeight = Metrics.Sidebar.navigationRowHeight
|
|
listView.rows {
|
|
ListRow(HostingListRow.self)
|
|
.height { item, _ in item.height }
|
|
.configure { [weak self] row, item, _ in
|
|
self?.configure(row, item: item)
|
|
}
|
|
}
|
|
return listView
|
|
}
|
|
|
|
func update(
|
|
listView: List,
|
|
items: [SidebarListItem],
|
|
renderer: @escaping Renderer,
|
|
locale: Locale,
|
|
colorScheme: ColorScheme,
|
|
animated: Bool
|
|
) {
|
|
content.update(
|
|
listView, items: items, renderer: renderer, locale: locale,
|
|
colorScheme: colorScheme, animated: animated
|
|
)
|
|
}
|
|
|
|
private func configure(_ row: HostingListRow, item: SidebarListItem) {
|
|
content.configure(row, item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wraps the list so the space below the last row has a menu of its own.
|
|
///
|
|
/// A row's own menu is installed by SwiftUI on its hosting view, which AppKit
|
|
/// finds first; only a click that landed on no row walks up the responder chain
|
|
/// this far. That is exactly the distinction the blank-area menu needs, and it
|
|
/// needs no geometry to make it.
|
|
@MainActor
|
|
final class SidebarListContainer: NSView {
|
|
var locale = Locale.current
|
|
let listView: ListView<SidebarListItem>
|
|
var blankAreaMenu: (() -> [NativeMenuEntry])?
|
|
|
|
init(listView: ListView<SidebarListItem>) {
|
|
self.listView = listView
|
|
super.init(frame: .zero)
|
|
listView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(listView)
|
|
NSLayoutConstraint.activate([
|
|
listView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
listView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
listView.topAnchor.constraint(equalTo: topAnchor),
|
|
listView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) { fatalError() }
|
|
|
|
override func menu(for event: NSEvent) -> NSMenu? {
|
|
guard let entries = blankAreaMenu?(), !entries.isEmpty else {
|
|
return super.menu(for: event)
|
|
}
|
|
return NativeMenuEntry.menu(entries, locale: locale)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
enum TerminalSidebarMenuBuilder {
|
|
static func snippets(
|
|
snippet: Snippet?, canInsert: Bool, locale: Locale,
|
|
copy: @escaping (Snippet) -> Void,
|
|
insert: @escaping (Snippet) -> Void
|
|
) -> [NativeMenuEntry] {
|
|
[
|
|
NativeMenuEntry(
|
|
title: localized(AppStrings.Common.copy, locale: locale),
|
|
symbol: MenuSymbol.copy,
|
|
isEnabled: snippet != nil
|
|
) {
|
|
if let snippet { copy(snippet) }
|
|
},
|
|
NativeMenuEntry(
|
|
title: localized(AppStrings.Common.insert, locale: locale),
|
|
symbol: MenuSymbol.insert,
|
|
isEnabled: snippet != nil && canInsert
|
|
) {
|
|
if let snippet { insert(snippet) }
|
|
},
|
|
]
|
|
}
|
|
|
|
static func history(
|
|
entry: CommandHistoryEntry?, canInsert: Bool, locale: Locale,
|
|
copy: @escaping (CommandHistoryEntry) -> Void,
|
|
insert: @escaping (CommandHistoryEntry) -> Void,
|
|
saveAsSnippet: @escaping (CommandHistoryEntry) -> Void
|
|
) -> [NativeMenuEntry] {
|
|
let entries = [
|
|
NativeMenuEntry(
|
|
title: localized(AppStrings.Common.copy, locale: locale),
|
|
symbol: MenuSymbol.copy,
|
|
isEnabled: entry != nil
|
|
) {
|
|
if let entry { copy(entry) }
|
|
},
|
|
NativeMenuEntry(
|
|
title: localized(AppStrings.Common.insert, locale: locale),
|
|
symbol: MenuSymbol.insert,
|
|
isEnabled: entry != nil && canInsert
|
|
) {
|
|
if let entry { insert(entry) }
|
|
},
|
|
NativeMenuEntry(
|
|
title: localized(AppStrings.Snippets.saveAsSnippet, locale: locale),
|
|
symbol: MenuSymbol.saveAsSnippet,
|
|
isEnabled: entry != nil
|
|
) {
|
|
if let entry { saveAsSnippet(entry) }
|
|
},
|
|
]
|
|
return entries
|
|
}
|
|
|
|
private static func localized(
|
|
_ key: LocalizedStringResource, locale: Locale
|
|
) -> String {
|
|
AppLocalization.resolve(key, locale: locale)
|
|
}
|
|
}
|
|
|
|
// MARK: - Session Snippets
|
|
|
|
/// The global Snippet shelf inserts into the focused terminal. The full
|
|
/// Snippets page remains the place for executing a Snippet on its saved Targets.
|
|
private struct SnippetsSidebarView: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.locale) private var locale
|
|
|
|
@State private var query = ""
|
|
@State private var destructiveSnippet: Snippet?
|
|
@State private var selectedSnippetID: Snippet.ID?
|
|
@AppStorage(DesktopSettingsKey.snippetsSidebarCollapsedSections)
|
|
private var collapsedSections = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if displayedSnippets.isEmpty {
|
|
ContentUnavailableView.search(text: query)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
SidebarNativeList(
|
|
items: snippetListItems,
|
|
// A fold removes rows during the header's click. Matching
|
|
// Assistant, keep that structural update synchronous.
|
|
animatesStructuralChanges: false,
|
|
blankAreaMenu: { blankAreaMenu }
|
|
) { item in
|
|
switch item.content {
|
|
case .section(let section, let title, let count):
|
|
if section.snippetExpansionID == nil {
|
|
sidebarSectionTitle(title)
|
|
} else {
|
|
SnippetSidebarSectionHeaderRow(
|
|
section: section, title: title, count: count
|
|
)
|
|
}
|
|
case .snippet(let snippet):
|
|
snippetRow(snippet)
|
|
case .history, .sftpHistory, .connection:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
SidebarSearchFooter(
|
|
text: $query,
|
|
placeholder: localized(AppStrings.Snippets.searchSnippets)
|
|
)
|
|
}
|
|
.confirmationDialog(
|
|
AppStrings.Snippets.runADestructiveScriptConfirmation,
|
|
isPresented: Binding(
|
|
get: { destructiveSnippet != nil },
|
|
set: { if !$0 { destructiveSnippet = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: destructiveSnippet
|
|
) { snippet in
|
|
Button(AppStrings.Common.run.swiftUIKey, role: .destructive) { executeNow(snippet) }
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { destructiveSnippet = nil }
|
|
} message: { snippet in
|
|
let assessment = CommandRiskClassifier.assess(snippet.body)
|
|
Text(([snippet.body] + assessment.reasons).joined(separator: "\n"))
|
|
}
|
|
}
|
|
|
|
private var snippetListItems: [SidebarListItem] {
|
|
if !query.isEmpty {
|
|
return SnippetSidebarListBuilder.searchItems(
|
|
displayedSnippets, title: localized(AppStrings.Common.results)
|
|
)
|
|
}
|
|
var groups = packageSections.map {
|
|
SnippetSidebarGroup(
|
|
id: .package($0.package.id), title: $0.path, snippets: $0.snippets
|
|
)
|
|
}
|
|
if !ungrouped.isEmpty {
|
|
groups.append(
|
|
SnippetSidebarGroup(
|
|
id: .ungrouped, title: localized(AppStrings.Snippets.unpackaged), snippets: ungrouped
|
|
)
|
|
)
|
|
}
|
|
return SnippetSidebarListBuilder.groupedItems(
|
|
groups,
|
|
expansion: SnippetSidebarExpansionState(rawValue: collapsedSections)
|
|
)
|
|
}
|
|
|
|
/// With nothing under the pointer both verbs grey out rather than the menu
|
|
/// not appearing, so the shelf answers a right-click the same way wherever
|
|
/// it lands.
|
|
private var blankAreaMenu: [NativeMenuEntry] {
|
|
TerminalSidebarMenuBuilder.snippets(
|
|
snippet: nil, canInsert: canInsert, locale: locale,
|
|
copy: copy, insert: insert
|
|
)
|
|
}
|
|
|
|
private func snippetRow(_ snippet: Snippet) -> some View {
|
|
SSHSessionSnippetRow(
|
|
snippet: snippet, isSelected: selectedSnippetID == snippet.id
|
|
)
|
|
.termindSidebarRow(isSelected: selectedSnippetID == snippet.id) {
|
|
TerminalSidebarMenuBuilder.snippets(
|
|
snippet: snippet, canInsert: canInsert, locale: locale,
|
|
copy: copy, insert: insert
|
|
)
|
|
}
|
|
.onTapGesture(count: 2) { execute(snippet) }
|
|
.onTapGesture {
|
|
selectedSnippetID = snippet.id
|
|
insert(snippet)
|
|
}
|
|
}
|
|
|
|
private var displayedSnippets: [Snippet] {
|
|
query.isEmpty ? app.hostLibrary.snippets : app.hostLibrary.searchSnippets(query)
|
|
}
|
|
|
|
private var ungrouped: [Snippet] { app.hostLibrary.snippets(in: nil) }
|
|
|
|
private func localized(_ key: LocalizedStringResource) -> String {
|
|
AppLocalization.resolve(key, locale: locale)
|
|
}
|
|
|
|
private var packageSections: [(package: SnippetPackage, path: String, snippets: [Snippet])] {
|
|
flattenPackages(app.hostLibrary.rootSnippetPackages)
|
|
}
|
|
|
|
private func flattenPackages(
|
|
_ packages: [SnippetPackage]
|
|
) -> [(package: SnippetPackage, path: String, snippets: [Snippet])] {
|
|
packages.flatMap { package in
|
|
let current = (
|
|
package: package,
|
|
path: app.hostLibrary.snippetPackagePath(package.id) ?? package.name,
|
|
snippets: app.hostLibrary.snippets(in: package.id)
|
|
)
|
|
return [current] + flattenPackages(app.hostLibrary.childSnippetPackages(of: package.id))
|
|
}
|
|
}
|
|
|
|
private var canInsert: Bool {
|
|
window.selectedWorkspace?.focusedPane.sshSession?.status.isLive == true
|
|
|| window.selectedWorkspace?.focusedPane.telnetSession?.status.isLive == true
|
|
}
|
|
|
|
private func copy(_ snippet: Snippet) {
|
|
copyToPasteboard(snippet.body)
|
|
}
|
|
|
|
private func insert(_ snippet: Snippet) {
|
|
guard let workspace = window.selectedWorkspace, canInsert else { return }
|
|
workspace.deliverToFocusedTerminal(snippet.body)
|
|
}
|
|
|
|
private func execute(_ snippet: Snippet) {
|
|
guard canInsert else { return }
|
|
if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation {
|
|
destructiveSnippet = snippet
|
|
} else {
|
|
executeNow(snippet)
|
|
}
|
|
}
|
|
|
|
private func executeNow(_ snippet: Snippet) {
|
|
guard let workspace = window.selectedWorkspace, canInsert else {
|
|
destructiveSnippet = nil
|
|
return
|
|
}
|
|
workspace.deliverToFocusedTerminal(snippet.body, submit: true)
|
|
destructiveSnippet = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Focus-sensitive History
|
|
|
|
/// One card, four fillings.
|
|
///
|
|
/// The three histories and the Snippet shelf answer the same shape of question
|
|
/// about different things — what did I run, what did I open, where did I
|
|
/// connect, what have I saved — so they are one component rather than four
|
|
/// lists that drifted into four paddings, four heights and four ideas of where
|
|
/// the selection highlight goes.
|
|
private struct SidebarCard<Trailing: View>: View {
|
|
let symbol: String?
|
|
let title: String
|
|
var titleIsMonospaced = false
|
|
var titleLineLimit: Int?
|
|
let subtitle: String?
|
|
var subtitleIsMonospaced = true
|
|
var truncatesSubtitleInMiddle = false
|
|
let isSelected: Bool
|
|
@ViewBuilder var trailing: Trailing
|
|
|
|
@Environment(\.controlActiveState) private var controlActiveState
|
|
|
|
var body: some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
if let symbol {
|
|
Image(systemName: symbol)
|
|
.foregroundStyle(
|
|
SidebarSelectionStyle.iconForeground(
|
|
isSelected: isSelected,
|
|
controlActiveState: controlActiveState
|
|
)
|
|
)
|
|
.frame(width: Metrics.Sidebar.rowIcon)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(titleIsMonospaced ? .caption.monospaced() : .body)
|
|
.lineLimit(titleLineLimit ?? (titleIsMonospaced ? 2 : 1))
|
|
if let subtitle {
|
|
Text(subtitle)
|
|
.font(subtitleIsMonospaced ? .caption.monospaced() : .caption)
|
|
.foregroundStyle(
|
|
SidebarSelectionStyle.foreground(
|
|
isSelected: isSelected,
|
|
controlActiveState: controlActiveState,
|
|
unselected: AnyShapeStyle(.secondary)
|
|
)
|
|
)
|
|
.lineLimit(1)
|
|
.truncationMode(truncatesSubtitleInMiddle ? .middle : .tail)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: Metrics.Space.s)
|
|
|
|
trailing
|
|
}
|
|
.padding(.horizontal, Metrics.Space.m)
|
|
.frame(height: Metrics.Sidebar.historyCardHeight)
|
|
.contentShape(.rect)
|
|
}
|
|
}
|
|
|
|
enum DesktopHistoryPresentation {
|
|
static let limit = 100
|
|
|
|
static func limited<Entry>(_ entries: [Entry]) -> [Entry] {
|
|
Array(entries.prefix(limit))
|
|
}
|
|
}
|
|
|
|
private struct HistorySidebarView: View {
|
|
@Environment(WindowModel.self) private var window
|
|
|
|
var body: some View {
|
|
if window.selectedWorkspace?.focusedPane.sshSession != nil {
|
|
SSHCommandHistorySidebar()
|
|
} else if window.selectedWorkspace?.focusedPane.sftpChannel != nil {
|
|
SFTPBrowseHistorySidebar()
|
|
} else {
|
|
ConnectionHistorySidebar()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SSHCommandHistorySidebar: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@Environment(\.locale) private var locale
|
|
|
|
@State private var query = ""
|
|
@State private var destructiveEntry: CommandHistoryEntry?
|
|
@State private var namingEntryID: CommandHistoryEntry.ID?
|
|
@State private var snippetName = ""
|
|
@State private var selectedID: CommandHistoryEntry.ID?
|
|
|
|
private var sshSession: SSHSessionModel? {
|
|
window.selectedWorkspace?.focusedPane.sshSession
|
|
}
|
|
|
|
private var allEntries: [CommandHistoryEntry] {
|
|
guard let sshSession else { return [] }
|
|
_ = sshSession.commandHistory.revision
|
|
return DesktopHistoryPresentation.limited(sshSession.commandHistory.entries(matching: query))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if allEntries.isEmpty {
|
|
emptyState
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.sidebarBlankAreaMenu { globalMenu }
|
|
} else {
|
|
SidebarNativeList(
|
|
items: historyListItems,
|
|
animatesStructuralChanges: false,
|
|
blankAreaMenu: { globalMenu }
|
|
) { item in
|
|
switch item.content {
|
|
case .section(_, let title, _):
|
|
sidebarSectionTitle(title)
|
|
case .history(let entry, _):
|
|
historyRow(entry)
|
|
case .snippet, .sftpHistory, .connection:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory))
|
|
}
|
|
.confirmationDialog(
|
|
AppStrings.Assistant.executeADestructiveCommandConfirmation,
|
|
isPresented: Binding(
|
|
get: { destructiveEntry != nil },
|
|
set: { if !$0 { destructiveEntry = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: destructiveEntry
|
|
) { entry in
|
|
Button(AppStrings.Common.execute.swiftUIKey, role: .destructive) { executeNow(entry) }
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { destructiveEntry = nil }
|
|
} message: { entry in
|
|
let assessment = CommandRiskClassifier.assess(entry.command)
|
|
Text(([entry.command] + assessment.reasons).joined(separator: "\n"))
|
|
}
|
|
}
|
|
|
|
/// The blank area offers the row menu with entry-specific actions disabled.
|
|
private var globalMenu: [NativeMenuEntry] {
|
|
historyMenu(for: nil)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func historyRow(_ entry: CommandHistoryEntry) -> some View {
|
|
if namingEntryID == entry.id {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
SidebarInlineTextField(
|
|
text: $snippetName,
|
|
prompt: String(localized: AppStrings.Snippets.snippetName),
|
|
onSubmit: { saveSnippet(from: entry) },
|
|
onCancel: cancelSnippetNaming
|
|
).singleLinePaste()
|
|
.frame(height: 18)
|
|
.padding(.horizontal, Metrics.Space.s)
|
|
.padding(.vertical, 4)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.small)
|
|
.strokeBorder(Color.accentColor.opacity(0.55))
|
|
}
|
|
Text(CommandHistoryPresentation.title(for: entry))
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.padding(.horizontal, Metrics.Space.m)
|
|
.termindSidebarRowBackground(isSelected: false)
|
|
} else {
|
|
CommandHistoryCard(
|
|
entry: entry,
|
|
isSelected: selectedID == entry.id
|
|
)
|
|
.onTapGesture(count: 2) {
|
|
selectedID = entry.id
|
|
execute(entry)
|
|
}
|
|
.onTapGesture {
|
|
selectedID = entry.id
|
|
insert(entry)
|
|
}
|
|
.termindSidebarRow(isSelected: selectedID == entry.id) {
|
|
historyMenu(for: entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func historyMenu(for entry: CommandHistoryEntry?) -> [NativeMenuEntry] {
|
|
TerminalSidebarMenuBuilder.history(
|
|
entry: entry,
|
|
canInsert: canInsert,
|
|
locale: locale,
|
|
copy: { copyToPasteboard($0.command) },
|
|
insert: insert,
|
|
saveAsSnippet: beginSnippetNaming
|
|
)
|
|
}
|
|
|
|
private var historyListItems: [SidebarListItem] {
|
|
let entries = allEntries.map {
|
|
SidebarListItem.history($0, isNaming: namingEntryID == $0.id)
|
|
}
|
|
guard !query.isEmpty else { return entries }
|
|
return [.section(.results, title: String(localized: AppStrings.Common.results))] + entries
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
if !query.isEmpty {
|
|
ContentUnavailableView.search(text: query)
|
|
} else {
|
|
ContentUnavailableView(
|
|
AppStrings.Common.noHistory.swiftUIKey, systemImage: "clock.arrow.circlepath",
|
|
description: Text(AppStrings.Terminal.commandsConfirmedByTheRemoteShellAppearHere)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func insert(_ entry: CommandHistoryEntry) {
|
|
guard let workspace = window.selectedWorkspace,
|
|
let sshSession = workspace.focusedPane.sshSession,
|
|
sshSession.status.isLive
|
|
else { return }
|
|
workspace.deliver(entry.command, from: sshSession)
|
|
}
|
|
|
|
private func execute(_ entry: CommandHistoryEntry) {
|
|
guard canInsert else { return }
|
|
if CommandRiskClassifier.assess(entry.command).requiresSecondConfirmation {
|
|
destructiveEntry = entry
|
|
} else {
|
|
executeNow(entry)
|
|
}
|
|
}
|
|
|
|
private func executeNow(_ entry: CommandHistoryEntry) {
|
|
guard let workspace = window.selectedWorkspace,
|
|
let sshSession = workspace.focusedPane.sshSession,
|
|
sshSession.status.isLive
|
|
else {
|
|
destructiveEntry = nil
|
|
return
|
|
}
|
|
workspace.deliver(entry.command, from: sshSession, submit: true)
|
|
destructiveEntry = nil
|
|
}
|
|
|
|
private func beginSnippetNaming(_ entry: CommandHistoryEntry) {
|
|
snippetName = ""
|
|
namingEntryID = entry.id
|
|
}
|
|
|
|
private var canInsert: Bool {
|
|
sshSession?.status.isLive == true
|
|
}
|
|
|
|
private func cancelSnippetNaming() {
|
|
namingEntryID = nil
|
|
snippetName = ""
|
|
}
|
|
|
|
private func saveSnippet(from entry: CommandHistoryEntry) {
|
|
let name = snippetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { return }
|
|
app.hostLibrary.upsert(Snippet(title: name, body: entry.command))
|
|
cancelSnippetNaming()
|
|
}
|
|
}
|
|
|
|
private struct SFTPBrowseHistorySidebar: View {
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
|
|
@State private var query = ""
|
|
@State private var selectedID: SFTPHistoryEntry.ID?
|
|
|
|
private var session: SFTPChannelModel? {
|
|
window.selectedWorkspace?.focusedPane.sftpChannel
|
|
}
|
|
|
|
private var entries: [SFTPHistoryEntry] {
|
|
guard let session else { return [] }
|
|
_ = session.history.revision
|
|
let values = session.history.entries()
|
|
let matches =
|
|
query.isEmpty
|
|
? values
|
|
: values.filter {
|
|
$0.path.localizedCaseInsensitiveContains(query)
|
|
|| session.displayPath($0.path).localizedCaseInsensitiveContains(query)
|
|
}
|
|
return DesktopHistoryPresentation.limited(matches)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if entries.isEmpty {
|
|
Group {
|
|
if query.isEmpty {
|
|
ContentUnavailableView(
|
|
AppStrings.Common.noHistory.swiftUIKey, systemImage: "clock.arrow.circlepath",
|
|
description: Text(AppStrings.SFTP.foldersYouVisitAndFilesYouOpenAppearHere)
|
|
)
|
|
} else {
|
|
ContentUnavailableView.search(text: query)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.sidebarBlankAreaMenu { globalMenu }
|
|
} else {
|
|
SidebarNativeList(
|
|
items: entries.map {
|
|
SidebarListItem.sftpHistory(
|
|
$0, displayedPath: session?.displayPath($0.path)
|
|
)
|
|
},
|
|
animatesStructuralChanges: false,
|
|
blankAreaMenu: { globalMenu }
|
|
) { item in
|
|
if case .sftpHistory(let entry, let displayedPath) = item.content {
|
|
row(entry, displayedPath: displayedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory))
|
|
}
|
|
}
|
|
|
|
private var globalMenu: [NativeMenuEntry] { [] }
|
|
|
|
private func row(_ entry: SFTPHistoryEntry, displayedPath: String) -> some View {
|
|
SidebarCard(
|
|
symbol: entry.kind.symbol,
|
|
title: entry.name,
|
|
subtitle: displayedPath,
|
|
truncatesSubtitleInMiddle: true,
|
|
isSelected: selectedID == entry.id
|
|
) {
|
|
EmptyView()
|
|
}
|
|
.onTapGesture(count: 2) { reopen(entry) }
|
|
.onTapGesture { selectedID = entry.id }
|
|
.termindSidebarRow(isSelected: selectedID == entry.id) {
|
|
[
|
|
NativeMenuEntry(title: AppStrings.Common.open, symbol: MenuSymbol.open) {
|
|
reopen(entry)
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
private func reopen(_ entry: SFTPHistoryEntry) {
|
|
guard let session else { return }
|
|
switch entry.kind {
|
|
case .directory:
|
|
session.navigate(to: entry.path)
|
|
case .file:
|
|
session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers, edits: app.sftpEdits) {
|
|
[history = session.history] in
|
|
if NSWorkspace.shared.open($0) {
|
|
history.record(kind: .file, path: entry.path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ConnectionHistorySidebar: View {
|
|
@Environment(\.locale) private var locale
|
|
@Environment(AppModel.self) private var app
|
|
@Environment(WindowModel.self) private var window
|
|
@State private var query = ""
|
|
@State private var selectedID: ConnectionHistoryEntry.ID?
|
|
@State private var confirmsClear = false
|
|
|
|
private var allRows: [(entry: ConnectionHistoryEntry, host: Host)] {
|
|
_ = app.connectionHistory.revision
|
|
return app.connectionHistory.entries().compactMap { entry in
|
|
app.hostLibrary.host(entry.hostID).map { (entry, $0) }
|
|
}
|
|
}
|
|
|
|
private var rows: [(entry: ConnectionHistoryEntry, host: Host)] {
|
|
let matches =
|
|
query.isEmpty
|
|
? allRows
|
|
: allRows.filter { entry, host in
|
|
host.matches(query)
|
|
|| historyAddress(entry, host: host)
|
|
.localizedCaseInsensitiveContains(query)
|
|
|| entry.kind.label.localizedCaseInsensitiveContains(query)
|
|
|| app.devices.name(for: entry.sourceDeviceID)?
|
|
.localizedCaseInsensitiveContains(query) == true
|
|
}
|
|
return DesktopHistoryPresentation.limited(matches)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if rows.isEmpty {
|
|
Group {
|
|
if query.isEmpty {
|
|
ContentUnavailableView(
|
|
AppStrings.Common.noHistory.swiftUIKey, systemImage: "clock.arrow.circlepath",
|
|
description: Text(AppStrings.Connections.hostsYouConnectToAppearHere)
|
|
)
|
|
} else {
|
|
ContentUnavailableView.search(text: query)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.sidebarBlankAreaMenu { globalMenu }
|
|
} else {
|
|
// Never animated. Reopening a record inserts a new one at the
|
|
// top, and animating that insertion moves the clicked row.
|
|
SidebarNativeList(
|
|
items: rows.map { entry, host in
|
|
.connection(
|
|
entry, host: host,
|
|
address: historyAddress(entry, host: host)
|
|
)
|
|
},
|
|
animatesStructuralChanges: false,
|
|
blankAreaMenu: { globalMenu }
|
|
) { item in
|
|
if case .connection(let entry, let host, let address) = item.content {
|
|
row(entry, host: host, address: address)
|
|
}
|
|
}
|
|
}
|
|
|
|
SidebarSearchFooter(text: $query, placeholder: String(localized: AppStrings.Common.searchHistory))
|
|
}
|
|
.confirmationDialog(
|
|
AppStrings.Connections.clearConnectionHistoryConfirmation,
|
|
isPresented: $confirmsClear,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
|
|
app.clearConnectionHistory()
|
|
}
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {}
|
|
} message: {
|
|
Text(AppStrings.Workspace.clearHistoryWarning)
|
|
}
|
|
}
|
|
|
|
private func historyAddress(_ entry: ConnectionHistoryEntry, host: Host) -> String {
|
|
app.hostLibrary.loginAddress(
|
|
for: host, protocol: entry.kind == .telnet ? .telnet : .ssh)
|
|
}
|
|
|
|
private var globalMenu: [NativeMenuEntry] {
|
|
[
|
|
NativeMenuEntry(
|
|
title: AppStrings.Vault.quickConnect,
|
|
symbol: MenuSymbol.quickConnect,
|
|
isEnabled: false
|
|
),
|
|
NativeMenuEntry(
|
|
title: AppStrings.Common.delete,
|
|
symbol: MenuSymbol.delete,
|
|
isEnabled: false
|
|
),
|
|
.separator,
|
|
NativeMenuEntry(
|
|
title: AppStrings.Common.clearAll,
|
|
symbol: MenuSymbol.clearHistory,
|
|
isEnabled: !allRows.isEmpty,
|
|
) { confirmsClear = true },
|
|
]
|
|
}
|
|
|
|
private func row(
|
|
_ entry: ConnectionHistoryEntry, host: Host, address: String
|
|
) -> some View {
|
|
SidebarCard(
|
|
symbol: entry.kind.symbol,
|
|
title: host.name,
|
|
subtitle: address,
|
|
isSelected: selectedID == entry.id
|
|
) {
|
|
VStack(alignment: .trailing, spacing: 1) {
|
|
Text(app.devices.name(for: entry.sourceDeviceID) ?? "—")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
Text(entry.connectedAt, format: .relative(presentation: .numeric).locale(locale))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
.layoutPriority(1)
|
|
}
|
|
.onTapGesture {
|
|
selectedID = entry.id
|
|
reopen(entry, host: host)
|
|
}
|
|
.termindSidebarRow(isSelected: selectedID == entry.id) {
|
|
HostMenuEntries.entries(
|
|
for: host, app: app, window: window, includesCopySSHCommand: false
|
|
) + [
|
|
NativeMenuEntry(
|
|
title: AppStrings.Common.delete,
|
|
symbol: MenuSymbol.delete,
|
|
) { app.connectionHistory.delete(entry.id) },
|
|
.separator,
|
|
NativeMenuEntry(
|
|
title: AppStrings.Common.clearAll,
|
|
symbol: MenuSymbol.clearHistory,
|
|
) { confirmsClear = true },
|
|
]
|
|
}
|
|
}
|
|
|
|
private func reopen(_ entry: ConnectionHistoryEntry, host: Host) {
|
|
let target: HostOpenTarget =
|
|
switch entry.kind {
|
|
case .ssh: .sshSession
|
|
case .mosh: .moshSession
|
|
case .eternalTerminal: .eternalTerminalSession
|
|
case .telnet: .telnetSession
|
|
case .sftp: .sftpChannel
|
|
}
|
|
app.open(host, as: target, in: window)
|
|
}
|
|
}
|
|
|
|
/// The blank-area menu for an empty-state view, which has no list to hang one on.
|
|
private struct SidebarBlankAreaMenu: ViewModifier {
|
|
let entries: () -> [NativeMenuEntry]
|
|
|
|
func body(content: Content) -> some View {
|
|
content.overlay {
|
|
NativeBlankAreaMenu(entries: { _ in entries() })
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
fileprivate func sidebarBlankAreaMenu(
|
|
_ entries: @escaping () -> [NativeMenuEntry]
|
|
) -> some View {
|
|
modifier(SidebarBlankAreaMenu(entries: entries))
|
|
}
|
|
}
|
|
|
|
/// A command as it appears in History: what was run, and how alarming it is.
|
|
private struct CommandHistoryCard: View {
|
|
let entry: CommandHistoryEntry
|
|
let isSelected: Bool
|
|
@Environment(\.locale) private var locale
|
|
|
|
var body: some View {
|
|
let subtitle = CommandHistoryPresentation.subtitle(for: entry, locale: locale)
|
|
SidebarCard(
|
|
symbol: nil,
|
|
title: CommandHistoryPresentation.title(for: entry),
|
|
titleIsMonospaced: true,
|
|
titleLineLimit: 1,
|
|
subtitle: subtitle.isEmpty ? nil : subtitle,
|
|
subtitleIsMonospaced: false,
|
|
isSelected: isSelected
|
|
) {
|
|
RiskBadge(assessment: CommandRiskClassifier.assess(entry.command), compact: true)
|
|
.frame(width: 18, height: 18, alignment: .center)
|
|
}
|
|
.help(Text(entry.command))
|
|
}
|
|
}
|
|
|
|
/// A native borderless field whose editor remains transparent inside a Sidebar
|
|
/// list. SwiftUI's focused `TextField` paints an opaque control background here,
|
|
/// which looks like a white strip in light mode and ignores the row material.
|
|
private struct SidebarInlineTextField: NSViewRepresentable {
|
|
@Binding var text: String
|
|
let prompt: String
|
|
let onSubmit: () -> Void
|
|
let onCancel: () -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(text: $text, onSubmit: onSubmit, onCancel: onCancel)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSTextField {
|
|
let field = NSTextField()
|
|
field.delegate = context.coordinator
|
|
field.target = context.coordinator
|
|
field.action = #selector(Coordinator.submit)
|
|
field.stringValue = text
|
|
field.isBezeled = false
|
|
field.isBordered = false
|
|
field.drawsBackground = false
|
|
field.focusRingType = .none
|
|
field.lineBreakMode = .byTruncatingTail
|
|
field.cell?.usesSingleLineMode = true
|
|
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
configure(field)
|
|
context.coordinator.requestFocus(for: field)
|
|
return field
|
|
}
|
|
|
|
func updateNSView(_ field: NSTextField, context: Context) {
|
|
if field.stringValue != text {
|
|
field.stringValue = text
|
|
}
|
|
configure(field)
|
|
}
|
|
|
|
private func configure(_ field: NSTextField) {
|
|
let font = NSFont.preferredFont(forTextStyle: .body)
|
|
field.font = font
|
|
field.textColor = .labelColor
|
|
field.placeholderAttributedString = NSAttributedString(
|
|
string: prompt,
|
|
attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]
|
|
)
|
|
field.currentEditor()?.textColor = .labelColor
|
|
field.currentEditor()?.drawsBackground = false
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSTextFieldDelegate {
|
|
private var text: Binding<String>
|
|
private let onSubmit: () -> Void
|
|
private let onCancel: () -> Void
|
|
private var didRequestFocus = false
|
|
|
|
init(
|
|
text: Binding<String>,
|
|
onSubmit: @escaping () -> Void,
|
|
onCancel: @escaping () -> Void
|
|
) {
|
|
self.text = text
|
|
self.onSubmit = onSubmit
|
|
self.onCancel = onCancel
|
|
}
|
|
|
|
func requestFocus(for field: NSTextField) {
|
|
guard !didRequestFocus else { return }
|
|
didRequestFocus = true
|
|
DispatchQueue.main.async {
|
|
field.window?.makeFirstResponder(field)
|
|
field.currentEditor()?.textColor = .labelColor
|
|
field.currentEditor()?.drawsBackground = false
|
|
}
|
|
}
|
|
|
|
func controlTextDidChange(_ notification: Notification) {
|
|
guard let field = notification.object as? NSTextField,
|
|
let value = SingleLineFieldEditing.normalizedValue(in: field)
|
|
else { return }
|
|
text.wrappedValue = value
|
|
}
|
|
|
|
func control(
|
|
_ control: NSControl,
|
|
textView: NSTextView,
|
|
doCommandBy commandSelector: Selector
|
|
) -> Bool {
|
|
guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else {
|
|
return false
|
|
}
|
|
onCancel()
|
|
return true
|
|
}
|
|
|
|
@objc func submit() {
|
|
onSubmit()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A snippet on the shelf, built from the same card as the three histories —
|
|
/// name over script, one height, one set of insets. The shelf and History are
|
|
/// the same kind of list and used to be two slightly different ones.
|
|
private struct SSHSessionSnippetRow: View {
|
|
let snippet: Snippet
|
|
let isSelected: Bool
|
|
|
|
private var assessment: RiskAssessment { CommandRiskClassifier.assess(snippet.body) }
|
|
|
|
var body: some View {
|
|
SidebarCard(
|
|
symbol: nil,
|
|
title: snippet.title,
|
|
subtitle: snippet.body,
|
|
isSelected: isSelected
|
|
) {
|
|
RiskBadge(assessment: assessment, compact: true)
|
|
}
|
|
.help(Text(snippet.body))
|
|
}
|
|
}
|