diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 52f661bf..36d2719f 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -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 diff --git a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift index 953977f2..e230d462 100644 --- a/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift +++ b/Termind/Features/ContextPanel/ContextPanelDetailCatalog.swift @@ -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) } diff --git a/Termind/Features/ContextPanel/ContextPanelView.swift b/Termind/Features/ContextPanel/ContextPanelView.swift index cf336468..e971287f 100644 --- a/Termind/Features/ContextPanel/ContextPanelView.swift +++ b/Termind/Features/ContextPanel/ContextPanelView.swift @@ -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 + + 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 @@ -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) diff --git a/Termind/Features/Shell/WindowRoute.swift b/Termind/Features/Shell/WindowRoute.swift index 11da1fd8..3578cb62 100644 --- a/Termind/Features/Shell/WindowRoute.swift +++ b/Termind/Features/Shell/WindowRoute.swift @@ -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" } } } diff --git a/Termind/Features/Snippets/SnippetsPageView.swift b/Termind/Features/Snippets/SnippetsPageView.swift index 498a284c..96917453 100644 --- a/Termind/Features/Snippets/SnippetsPageView.swift +++ b/Termind/Features/Snippets/SnippetsPageView.swift @@ -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 { diff --git a/Termind/Features/Tabs/WindowState.swift b/Termind/Features/Tabs/WindowState.swift index 4ec48985..848576a0 100644 --- a/Termind/Features/Tabs/WindowState.swift +++ b/Termind/Features/Tabs/WindowState.swift @@ -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" } } diff --git a/TermindTests/ListViewMigrationTests.swift b/TermindTests/ListViewMigrationTests.swift index 05697ddc..3ee05a3a 100644 --- a/TermindTests/ListViewMigrationTests.swift +++ b/TermindTests/ListViewMigrationTests.swift @@ -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), + ] + ) + } }