Add collapsible snippet sidebar sections
This commit is contained in:
1 parent
865d92596d
commit
a40d6da2ba
7 files changed
+221
-23
No files matched your search
@@ -27,6 +27,7 @@ nonisolated enum SettingsKey {
|
||||
static let workspaceSectionExpanded = "sidebar.section.workspace.expanded"
|
||||
static let chatsSectionExpanded = "sidebar.section.chats.expanded"
|
||||
static let pinnedChatsSectionExpanded = "sidebar.section.pinnedChats.expanded"
|
||||
static let snippetsSidebarCollapsedSections = "sidebar.snippets.collapsedSections"
|
||||
static let recentsSectionExpanded = "sidebar.section.recents.expanded"
|
||||
/// Whether the floating status capsule shows its session details or is
|
||||
/// shrunk to its status dot. App-wide: it is a standing preference about
|
||||
|
||||
@@ -173,7 +173,7 @@ private struct SnippetContextPanelDetailProvider: ContextPanelDetailProvider {
|
||||
let kind = ContextPanelDetailID.Kind.snippet
|
||||
let page = VaultPage.snippets
|
||||
let emptyState = ContextPanelEmptyState(
|
||||
title: "No Snippet Selected", symbol: "text.badge.plus",
|
||||
title: "No Snippet Selected", symbol: "curlybraces",
|
||||
description: "Select a snippet to inspect it."
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ private struct SnippetContextPanelDetailProvider: ContextPanelDetailProvider {
|
||||
id: id,
|
||||
label: AnyView(
|
||||
ContextPanelDetailLabel {
|
||||
Image(systemName: "text.badge.plus").foregroundStyle(.accent)
|
||||
Image(systemName: "curlybraces").foregroundStyle(.accent)
|
||||
} title: {
|
||||
ContextPanelDetailTitle(title: snippet.title, subtitle: snippet.body)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ nonisolated struct ContextSidebarListItem: Identifiable, 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 {
|
||||
@@ -150,7 +158,10 @@ nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
nonisolated enum Content: Hashable, Sendable {
|
||||
case section(SectionID, title: String)
|
||||
/// 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)
|
||||
@@ -160,8 +171,8 @@ nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable {
|
||||
let id: ID
|
||||
let content: Content
|
||||
|
||||
static func section(_ id: SectionID, title: String) -> Self {
|
||||
.init(id: .section(id), content: .section(id, title: title))
|
||||
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 {
|
||||
@@ -186,7 +197,8 @@ nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable {
|
||||
/// squeezed into a row built for two lines of text.
|
||||
var height: CGFloat {
|
||||
switch content {
|
||||
case .section: 32
|
||||
case .section(let section, _, _):
|
||||
section.snippetExpansionID == nil ? 32 : 34
|
||||
case .snippet: Metrics.Sidebar.historyCardHeight
|
||||
case .history(_, let isNaming):
|
||||
isNaming
|
||||
@@ -197,6 +209,65 @@ nonisolated struct ContextSidebarListItem: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: ContextSidebarListItem.SectionID) -> Bool {
|
||||
guard let id = section.snippetExpansionID else { return true }
|
||||
return !collapsedSectionIDs.contains(id)
|
||||
}
|
||||
|
||||
mutating func setExpanded(
|
||||
_ expanded: Bool,
|
||||
for section: ContextSidebarListItem.SectionID
|
||||
) {
|
||||
guard let id = section.snippetExpansionID else { return }
|
||||
if expanded {
|
||||
collapsedSectionIDs.remove(id)
|
||||
} else {
|
||||
collapsedSectionIDs.insert(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct SnippetSidebarGroup: Sendable {
|
||||
let id: ContextSidebarListItem.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
|
||||
) -> [ContextSidebarListItem] {
|
||||
var items: [ContextSidebarListItem] = []
|
||||
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(ContextSidebarListItem.snippet))
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
static func searchItems(_ snippets: [Snippet]) -> [ContextSidebarListItem] {
|
||||
[.section(.results, title: "Results", count: snippets.count)]
|
||||
+ snippets.map(ContextSidebarListItem.snippet)
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading above a group of cards.
|
||||
///
|
||||
/// Inset exactly like `SidebarSectionHeader` in the Navigator and the Assistant,
|
||||
@@ -214,6 +285,58 @@ private func contextSidebarSectionTitle(_ title: String) -> some View {
|
||||
.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 `ContextSidebarListItem` representing this row.
|
||||
private struct SnippetSidebarSectionHeaderRow: View {
|
||||
let section: ContextSidebarListItem.SectionID
|
||||
let title: String
|
||||
let count: Int
|
||||
|
||||
@AppStorage(SettingsKey.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 {
|
||||
Button(action: toggleExpansion) {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Text(title)
|
||||
Spacer(minLength: 0)
|
||||
Text(count, format: .number)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.tertiary)
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 34)
|
||||
.padding(
|
||||
.horizontal,
|
||||
Metrics.Sidebar.navigationRowInset + Metrics.Sidebar.rowHorizontalMargin
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.termindSidebarSectionLabel()
|
||||
.accessibilityValue(Text(isExpanded ? "Expanded" : "Collapsed"))
|
||||
.accessibilityAction(
|
||||
named: Text(isExpanded ? "Collapse" : "Expand"),
|
||||
toggleExpansion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContextSidebarNativeList: NSViewRepresentable {
|
||||
typealias NSViewType = ContextSidebarListContainer
|
||||
typealias List = ListView<ContextSidebarListItem>
|
||||
@@ -392,6 +515,8 @@ private struct SSHSessionSnippetsSidebar: View {
|
||||
@State private var query = ""
|
||||
@State private var destructiveSnippet: Snippet?
|
||||
@State private var selectedSnippetID: Snippet.ID?
|
||||
@AppStorage(SettingsKey.snippetsSidebarCollapsedSections)
|
||||
private var collapsedSections = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -401,11 +526,20 @@ private struct SSHSessionSnippetsSidebar: View {
|
||||
} else {
|
||||
ContextSidebarNativeList(
|
||||
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 title):
|
||||
contextSidebarSectionTitle(title)
|
||||
case .section(let section, let title, let count):
|
||||
if section.snippetExpansionID == nil {
|
||||
contextSidebarSectionTitle(title)
|
||||
} else {
|
||||
SnippetSidebarSectionHeaderRow(
|
||||
section: section, title: title, count: count
|
||||
)
|
||||
}
|
||||
case .snippet(let snippet):
|
||||
snippetRow(snippet)
|
||||
case .history, .sftpHistory, .connection:
|
||||
@@ -435,19 +569,22 @@ private struct SSHSessionSnippetsSidebar: View {
|
||||
|
||||
private var snippetListItems: [ContextSidebarListItem] {
|
||||
if !query.isEmpty {
|
||||
return [.section(.results, title: "Results")]
|
||||
+ displayedSnippets.map(ContextSidebarListItem.snippet)
|
||||
return SnippetSidebarListBuilder.searchItems(displayedSnippets)
|
||||
}
|
||||
var items: [ContextSidebarListItem] = []
|
||||
for section in packageSections where !section.snippets.isEmpty {
|
||||
items.append(.section(.package(section.package.id), title: section.path))
|
||||
items.append(contentsOf: section.snippets.map(ContextSidebarListItem.snippet))
|
||||
var groups = packageSections.map {
|
||||
SnippetSidebarGroup(
|
||||
id: .package($0.package.id), title: $0.path, snippets: $0.snippets
|
||||
)
|
||||
}
|
||||
if !ungrouped.isEmpty {
|
||||
items.append(.section(.ungrouped, title: "Ungrouped"))
|
||||
items.append(contentsOf: ungrouped.map(ContextSidebarListItem.snippet))
|
||||
groups.append(
|
||||
SnippetSidebarGroup(id: .ungrouped, title: "Ungrouped", snippets: ungrouped)
|
||||
)
|
||||
}
|
||||
return items
|
||||
return SnippetSidebarListBuilder.groupedItems(
|
||||
groups,
|
||||
expansion: SnippetSidebarExpansionState(rawValue: collapsedSections)
|
||||
)
|
||||
}
|
||||
|
||||
/// This shelf only runs what is already saved; snippets are made on the
|
||||
@@ -641,7 +778,7 @@ private struct SSHCommandHistorySidebar: View {
|
||||
blankAreaMenu: { globalMenu }
|
||||
) { item in
|
||||
switch item.content {
|
||||
case .section(_, let title):
|
||||
case .section(_, let title, _):
|
||||
contextSidebarSectionTitle(title)
|
||||
case .history(let entry, _):
|
||||
historyRow(entry)
|
||||
|
||||
@@ -33,7 +33,7 @@ nonisolated enum VaultPage: String, Hashable, Sendable, CaseIterable, Identifiab
|
||||
case .welcome: "house"
|
||||
case .hosts: "server.rack"
|
||||
case .keychain: "key.fill"
|
||||
case .snippets: "text.badge.plus"
|
||||
case .snippets: "curlybraces"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ struct SnippetsPageView: ManagedCardPage {
|
||||
|
||||
func managedEmptyState() -> some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Snippets", systemImage: "text.badge.plus")
|
||||
Label("No Snippets", systemImage: "curlybraces")
|
||||
} description: {
|
||||
Text("Save a script and optionally choose its default hosts.")
|
||||
} actions: {
|
||||
@@ -639,7 +639,7 @@ struct SnippetEditorSheet: View {
|
||||
private var editor: some View {
|
||||
VaultDetailScaffold(
|
||||
title: existing == nil ? "New Snippet" : "Edit Snippet",
|
||||
symbol: "text.badge.plus", close: close
|
||||
symbol: "curlybraces", close: close
|
||||
) {
|
||||
Form {
|
||||
Section {
|
||||
|
||||
@@ -65,7 +65,7 @@ final class WindowSidebarState {
|
||||
switch self {
|
||||
case .navigation: "list.bullet"
|
||||
case .assistant: "sparkles"
|
||||
case .snippets: "text.badge.plus"
|
||||
case .snippets: "curlybraces"
|
||||
case .history: "clock.arrow.circlepath"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,9 +182,69 @@ struct ListViewMigrationTests {
|
||||
func contextSidebarSectionIdentityDoesNotDependOnItsTitle() {
|
||||
let packageID = UUID()
|
||||
let short = ContextSidebarListItem.section(.package(packageID), title: "Tools")
|
||||
let renamed = ContextSidebarListItem.section(.package(packageID), title: "Operations")
|
||||
let renamed = ContextSidebarListItem.section(
|
||||
.package(packageID), title: "Operations", count: 3
|
||||
)
|
||||
|
||||
#expect(short.id == renamed.id)
|
||||
#expect(short != renamed)
|
||||
}
|
||||
|
||||
@Test
|
||||
func snippetSidebarExpansionIsIndependentAndRoundTrips() {
|
||||
let first = ContextSidebarListItem.SectionID.package(UUID())
|
||||
let second = ContextSidebarListItem.SectionID.package(UUID())
|
||||
var state = SnippetSidebarExpansionState()
|
||||
|
||||
#expect(state.isExpanded(first))
|
||||
#expect(state.isExpanded(second))
|
||||
#expect(state.isExpanded(.ungrouped))
|
||||
|
||||
state.setExpanded(false, for: first)
|
||||
state.setExpanded(false, for: .ungrouped)
|
||||
let restored = SnippetSidebarExpansionState(rawValue: state.rawValue)
|
||||
|
||||
#expect(!restored.isExpanded(first))
|
||||
#expect(restored.isExpanded(second))
|
||||
#expect(!restored.isExpanded(.ungrouped))
|
||||
#expect(restored.isExpanded(.results))
|
||||
}
|
||||
|
||||
@Test
|
||||
func snippetSidebarBuilderFoldsOnlyTheRequestedGroup() {
|
||||
let firstID = UUID()
|
||||
let secondID = UUID()
|
||||
let firstSnippet = Snippet(title: "First", body: "pwd")
|
||||
let secondSnippet = Snippet(title: "Second", body: "whoami")
|
||||
let groups = [
|
||||
SnippetSidebarGroup(
|
||||
id: .package(firstID), title: "Tools", snippets: [firstSnippet]
|
||||
),
|
||||
SnippetSidebarGroup(
|
||||
id: .package(secondID), title: "Operations", snippets: [secondSnippet]
|
||||
),
|
||||
]
|
||||
var expansion = SnippetSidebarExpansionState()
|
||||
expansion.setExpanded(false, for: .package(firstID))
|
||||
|
||||
let grouped = SnippetSidebarListBuilder.groupedItems(
|
||||
groups, expansion: expansion
|
||||
)
|
||||
#expect(
|
||||
grouped.map(\.id) == [
|
||||
.section(.package(firstID)),
|
||||
.section(.package(secondID)),
|
||||
.snippet(secondSnippet.id),
|
||||
]
|
||||
)
|
||||
|
||||
let search = SnippetSidebarListBuilder.searchItems([firstSnippet, secondSnippet])
|
||||
#expect(
|
||||
search.map(\.id) == [
|
||||
.section(.results),
|
||||
.snippet(firstSnippet.id),
|
||||
.snippet(secondSnippet.id),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user