diff --git a/Sources/Clavis/Components/LibrarySidebarComponents.swift b/Sources/Clavis/Components/LibrarySidebarComponents.swift new file mode 100644 index 0000000..9c9a343 --- /dev/null +++ b/Sources/Clavis/Components/LibrarySidebarComponents.swift @@ -0,0 +1,163 @@ +import AppKit +import SwiftUI +import ClavisCore + +/// Presentation derived from the existing purpose until capability metadata migrates. +/// This does not grant access or alter the daemon's authorization decisions. +struct LibraryKeyPresentation: Equatable { + let typeTitle: String + let symbol: String + let capabilities: [String] + + init(purpose: KeyPurpose) { + typeTitle = purpose == .agent ? "Bot" : "Personal" + symbol = purpose == .agent ? "desktopcomputer" : "person" + capabilities = purpose == .gitSigningOnly ? ["SSHSIG"] : ["SSH", "SSHSIG"] + } + + var accessibilitySummary: String { + ([typeTitle] + capabilities).joined(separator: ", ") + } +} + +struct LibraryMetadataBadge: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 4)) + .fixedSize() + } +} + +/// NSSearchField owns editing, the clear button, and native focus-ring rendering. +struct LibrarySearchField: NSViewRepresentable { + @Binding var text: String + @Binding var focused: Bool + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSSearchField { + let field = NSSearchField() + field.placeholderString = "Search keys" + field.setAccessibilityLabel("Search keys") + field.controlSize = .regular + field.sendsSearchStringImmediately = true + field.sendsWholeSearchString = false + field.delegate = context.coordinator + return field + } + + func updateNSView(_ field: NSSearchField, context: Context) { + context.coordinator.parent = self + if field.stringValue != text { field.stringValue = text } + guard focused, !context.coordinator.focusScheduled else { return } + guard field.currentEditor() == nil else { return } + context.coordinator.focusScheduled = true + DispatchQueue.main.async { [weak field, weak coordinator = context.coordinator] in + defer { coordinator?.focusScheduled = false } + guard let field, coordinator?.parent.focused == true else { return } + field.window?.makeFirstResponder(field) + } + } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + var parent: LibrarySearchField + var focusScheduled = false + + init(_ parent: LibrarySearchField) { self.parent = parent } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSSearchField else { return } + parent.text = field.stringValue + } + + func controlTextDidBeginEditing(_ notification: Notification) { + parent.focused = true + } + + func controlTextDidEndEditing(_ notification: Notification) { + parent.focused = false + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else { return false } + if !parent.text.isEmpty { + parent.text = "" + } else { + parent.focused = false + control.window?.makeFirstResponder(nil) + } + return true + } + } +} + +struct LibraryAgentStatus: View { + let active: Bool + + var body: some View { + HStack(spacing: 7) { + Circle() + .fill(active ? Color.green : Color.secondary) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text(active ? "Agent active" : "Agent unavailable") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 13) + } +} + +/// A standard menu provides native selection, dismissal, and keyboard behavior. +struct LibraryMoreMenu: View { + let history: () -> Void + let settings: () -> Void + let lock: () -> Void + let revoke: () -> Void + + var body: some View { + Menu { + Button(action: history) { + Label("Signing History", systemImage: "clock.arrow.circlepath") + } + Button(action: settings) { + Label("Settings…", systemImage: "gearshape") + } + Divider() + Button(action: lock) { + Label("Lock Clavis", systemImage: "lock") + } + Button(role: .destructive, action: revoke) { + Label("Revoke All Sessions…", systemImage: "stop.circle") + } + Divider() + Link(destination: URL(string: "https://github.com/GDR/clavis#readme")!) { + Label("Help & Documentation", systemImage: "questionmark.circle") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 15, weight: .medium)) + .frame(width: 33, height: 33) + .contentShape(Circle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .liquidGlassCapsuleControl() + .help("More") + .accessibilityLabel("More") + } +} diff --git a/Sources/Clavis/Views/KeyListView.swift b/Sources/Clavis/Views/KeyListView.swift index 5ed606d..0522d39 100644 --- a/Sources/Clavis/Views/KeyListView.swift +++ b/Sources/Clavis/Views/KeyListView.swift @@ -13,10 +13,10 @@ struct KeyListView: View { @State private var kindFilter: KeyKindFilter = .all @State private var searchText: String = "" @State private var keyPendingDeletion: SSHKeyInfo? = nil - @State private var isHoveringVerifiedBadge: Bool = false + @State private var confirmingRevokeAll = false @FocusState private var isSearchFieldFocused: Bool - public static let sidebarWidth: CGFloat = 222 + public static let sidebarWidth: CGFloat = 260 public static let toolbarHeight: CGFloat = 52 public static let searchCapsuleHeight: CGFloat = 36 public static let emptyStateTitle = "A key that stays on your Mac." @@ -298,82 +298,42 @@ struct KeyListView: View { return selectedKey?.id == key.id } - // MARK: - Left Sidebar (222px Contacts-style library) + // MARK: - Key library sidebar private var sidebarHeader: some View { - VStack(alignment: .leading, spacing: 0) { - // Top 52px sidebar toolbar (traffic lights on left, Lock + New Key on right) + VStack(spacing: 10) { HStack(spacing: 7) { Spacer(minLength: 72) - - AdaptiveGlassEffectContainer(spacing: 7) { - CircularGlassIconButton( - icon: "lock", - size: 33, - iconSize: 14, - helpText: "Lock control panel (⌘L)", - accessibilityLabel: "Lock control panel" - ) { - PanelLockController.shared.lock(reason: .lockNow) - } - - CircularGlassIconButton( - icon: "plus", - size: 33, - iconSize: 15, - isActive: appState.activeSheet == .create, - helpText: "New key (⌘N)", - accessibilityLabel: ClavisUIStrings.Accessibility.addKey - ) { - appState.activeSheet = .create - } - } + CircularGlassIconButton( + icon: "plus", size: 33, iconSize: 15, + isActive: appState.activeSheet == .create, + helpText: "New key (⌘N)", + accessibilityLabel: ClavisUIStrings.Accessibility.addKey + ) { appState.activeSheet = .create } + LibraryMoreMenu( + history: { appState.openHistoryPane(forKeyFingerprint: nil) }, + settings: { appState.activeSheet = .settings }, + lock: { PanelLockController.shared.lock(reason: .lockNow) }, + revoke: { confirmingRevokeAll = true } + ) } - .padding(.leading, 16) - .padding(.trailing, 12) + .padding(.horizontal, 12) .frame(height: Self.toolbarHeight) - // 36px Contacts-inspired search capsule (9.5pt inner leading + 8pt outer = 17.5pt icon leading, centering the ~17pt icon at x=26pt under the close button) - HStack(spacing: 7) { - Image(systemName: "magnifyingglass") - .foregroundColor(DesignTokens.textSecondary) - .font(.system(size: 13)) - - TextField("Search", text: $searchText) - .textFieldStyle(.plain) - .font(.system(size: 13)) - .focused($isSearchFieldFocused) - .accessibilityLabel("Search keys") - .onExitCommand { - if !searchText.isEmpty { - searchText = "" - } else { - isSearchFieldFocused = false - } - } - - if !searchText.isEmpty { - Button(action: { searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(DesignTokens.textTertiary) - .font(.system(size: 12)) - } - .buttonStyle(.plain) - .accessibilityLabel(ClavisUIStrings.Common.cancel) - } - } - .padding(.horizontal, 9.5) - .frame(height: Self.searchCapsuleHeight) - .liquidGlassCapsuleControl(isFocused: isSearchFieldFocused) - .padding(.horizontal, 8) + LibrarySearchField(text: $searchText, focused: Binding( + get: { isSearchFieldFocused }, + set: { isSearchFieldFocused = $0 } + )) + .frame(height: 28) + .padding(.horizontal, 12) .padding(.bottom, 8) - - // "Keys" section header - Text("Keys") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(DesignTokens.textSecondary) - .padding(.horizontal, 17) - .padding(.bottom, 5) + } + .confirmationDialog("Revoke all active sessions?", isPresented: $confirmingRevokeAll) { + Button("Revoke All Sessions", role: .destructive) { + appState.endGitSigningSession() + } + } message: { + Text("New requests will require approval. Existing SSH connections may remain open.") } } @@ -432,6 +392,12 @@ struct KeyListView: View { sidebarHeader } .frame(maxHeight: .infinity) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider().padding(.horizontal, 14) + LibraryAgentStatus(active: appState.isSocketActive) + } + } } .frame(width: Self.sidebarWidth) .frame(maxHeight: .infinity) @@ -481,7 +447,6 @@ struct KeyListView: View { } .topLiquidGlassBar { VStack(spacing: 0) { - mainToolbar if isHistoryActive { // One fixed region: search, badges and count never enter the scroll area. HistoryFilterHeader(viewModel: historyViewModel, ui: historyUI) @@ -493,106 +458,6 @@ struct KeyListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var mainToolbar: some View { - HStack(spacing: 12) { - HStack(spacing: 13) { - if isHistoryActive { - Button(action: leaveHistory) { - Image(systemName: "chevron.left") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(DesignTokens.textSecondary) - .frame(width: 24, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Back (⌘[)") - .accessibilityLabel("Back") - } - - Text(appState.activeMainPane == .history ? "History" : "Clavis") - .font(.system(size: 21, weight: .semibold)) - .foregroundColor(.primary) - - if appState.activeMainPane == .history, - historyViewModel.integrityReport?.status == .ok { - HStack(spacing: 5) { - Image(systemName: "checkmark.shield.fill") - .font(.system(size: 12)) - Text("Verified") - .font(.system(size: 11, weight: .medium)) - } - .foregroundColor(DesignTokens.accentGreen) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .contentShape(Rectangle()) - .help(Self.historyVerifiedTooltip) - .onHover { hovering in - isHoveringVerifiedBadge = hovering - } - .popover(isPresented: $isHoveringVerifiedBadge, arrowEdge: .bottom) { - Text(Self.historyVerifiedTooltip) - .font(.system(size: 11)) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .frame(width: 240, alignment: .leading) - .padding(10) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("Verified") - .accessibilityHint(Self.historyVerifiedTooltip) - } - } - - Spacer() - - HStack(spacing: 8) { - HStack(spacing: 5) { - StatusDot(isActive: appState.isSocketActive) - Text(appState.isSocketActive ? "Agent active" : "Agent offline") - .font(.system(size: 11)) - .foregroundColor(DesignTokens.textSecondary) - } - .padding(.trailing, 5) - .accessibilityElement(children: .combine) - .accessibilityLabel(ClavisUIStrings.Accessibility.agentStatus) - .accessibilityValue( - appState.isSocketActive - ? ClavisUIStrings.Accessibility.statusActive - : ClavisUIStrings.Accessibility.statusOffline - ) - - AdaptiveGlassEffectContainer(spacing: 8) { - CircularGlassIconButton( - icon: "clock.arrow.circlepath", - size: 34, - iconSize: 15, - isActive: appState.activeMainPane == .history, - helpText: "History (⇧⌘H)", - accessibilityLabel: "History" - ) { - toggleHistoryPane() - } - - CircularGlassIconButton( - icon: "gearshape", - size: 34, - iconSize: 15, - isActive: appState.activeSheet == .settings, - helpText: "Settings (⌘,)", - accessibilityLabel: ClavisUIStrings.Accessibility.settings - ) { - appState.activeSheet = .settings - } - } - } - } - // 20pt matches the grouped Form card inset below the header. - .padding(.leading, 20) - .padding(.trailing, 20) - .frame(height: Self.toolbarHeight) - .windowDragHandle() - } - private var emptyVaultFirstLaunchView: some View { VStack(spacing: 0) { Spacer() @@ -687,35 +552,30 @@ struct KeySidebarRowView: View { } var body: some View { - HStack(spacing: 11) { - // Circular lock status indicator (28x30) - ZStack { - Circle() - .fill(DesignTokens.accentBlue.opacity(0.09)) - Image(systemName: isUnlocked ? "lock.open" : "lock") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(isUnlocked ? DesignTokens.accentGreen : DesignTokens.accentOrange) - } - .frame(width: 28, height: 30) - .help(isUnlocked ? "Unlocked" : "Locked") - - // Key label & purpose + lock status - VStack(alignment: .leading, spacing: 3) { + let presentation = LibraryKeyPresentation(purpose: key.purpose) + HStack(spacing: 10) { + Image(systemName: presentation.symbol) + .font(.system(size: 17)) + .foregroundStyle(DesignTokens.accentBlue) + .frame(width: 22) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 5) { Text(key.label) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.primary) - .lineLimit(1) - Text(subtitleText) - .font(.system(size: 10)) - .foregroundColor(DesignTokens.textSecondary) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) .lineLimit(1) + HStack(spacing: 4) { + LibraryMetadataBadge(title: presentation.typeTitle) + Spacer(minLength: 4) + ForEach(presentation.capabilities, id: \.self) { + LibraryMetadataBadge(title: $0) + } + } } - - Spacer(minLength: 0) } .padding(.horizontal, 9) - .padding(.vertical, 12) - .frame(minHeight: 63) + .padding(.vertical, 9) + .frame(minHeight: 56) .background(FirstMouseView()) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) @@ -725,19 +585,10 @@ struct KeySidebarRowView: View { : (isHovered ? Color.primary.opacity(0.05) : Color.clear) ) ) - .overlay(alignment: .bottom) { - if showDivider { - Rectangle() - .fill(DesignTokens.cardBorder) - .frame(height: 1) - .padding(.leading, 49) - .padding(.trailing, 8) - } - } .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .accessibilityElement(children: .combine) .accessibilityLabel(key.label) - .accessibilityValue(subtitleText) + .accessibilityValue(LibraryKeyPresentation(purpose: key.purpose).accessibilitySummary) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton]) .accessibilityAction { onSelect() diff --git a/Tests/ClavisAppTests/KeyDetailInspectorTests.swift b/Tests/ClavisAppTests/KeyDetailInspectorTests.swift index 8e46228..4cfcaa7 100644 --- a/Tests/ClavisAppTests/KeyDetailInspectorTests.swift +++ b/Tests/ClavisAppTests/KeyDetailInspectorTests.swift @@ -915,10 +915,13 @@ struct KeyDetailInspectorTests { let scrollViews = collectScrollViews(in: hostingView) #expect(scrollViews.count == 2) - let leftSV = try #require(scrollViews.first { abs($0.frame.width - 222) < 1 && abs($0.frame.height - 400) < 1 }) - let rightSV = try #require(scrollViews.first { abs($0.frame.width - 689) < 1 && abs($0.frame.height - 400) < 1 }) + let leftSV = try #require(scrollViews.first { abs($0.frame.width - KeyListView.sidebarWidth) < 1 }) + let rightSV = try #require(scrollViews.first { abs($0.frame.width - (911 - KeyListView.sidebarWidth)) < 1 }) + // The fixed status footer consumes sidebar space; key content has no toolbar. + #expect(leftSV.frame.height <= 400) + #expect(rightSV.frame.height == 400) #expect(leftSV.contentInsets.top >= KeyListView.toolbarHeight) - #expect(rightSV.contentInsets.top >= KeyListView.toolbarHeight) + #expect(rightSV.contentInsets.top < KeyListView.toolbarHeight) func pocketSubviews(in sv: NSScrollView) -> [NSView] { sv.subviews.filter { @@ -966,12 +969,12 @@ struct KeyDetailInspectorTests { RunLoop.main.run(until: Date().addingTimeInterval(0.1)) hostingView.layoutSubtreeIfNeeded() let historyScrollViews = collectScrollViews(in: hostingView) - let historyRightSV = try #require(historyScrollViews.first { abs($0.frame.width - 689) < 1 && abs($0.frame.height - 400) < 1 }) + let historyRightSV = try #require(historyScrollViews.first { abs($0.frame.width - (911 - KeyListView.sidebarWidth)) < 1 && abs($0.frame.height - 400) < 1 }) #expect(historyRightSV.contentInsets.top >= KeyListView.toolbarHeight) #expect(!pocketSubviews(in: historyRightSV).isEmpty) #expect(pocketSubviews(in: historyRightSV).allSatisfy { $0.isHidden }) - historyRightSV.documentView?.setFrameSize(NSSize(width: 689, height: 800)) + historyRightSV.documentView?.setFrameSize(NSSize(width: 911 - KeyListView.sidebarWidth, height: 800)) historyRightSV.contentView.setBoundsOrigin(NSPoint(x: 0, y: -60)) NotificationCenter.default.post(name: NSView.boundsDidChangeNotification, object: historyRightSV.contentView) #expect(pocketSubviews(in: historyRightSV).allSatisfy { !$0.isHidden }) diff --git a/Tests/ClavisAppTests/KeyListViewTests.swift b/Tests/ClavisAppTests/KeyListViewTests.swift index 7a17fe2..bf45d4e 100644 --- a/Tests/ClavisAppTests/KeyListViewTests.swift +++ b/Tests/ClavisAppTests/KeyListViewTests.swift @@ -303,7 +303,7 @@ struct KeyListViewTests { @Test func testContactsSidebarAndEmptyStateConstants() { - #expect(KeyListView.sidebarWidth == 222) + #expect(KeyListView.sidebarWidth == 260) #expect(KeyListView.toolbarHeight == 52) #expect(KeyListView.searchCapsuleHeight == 36) #expect(KeyListView.emptyStateTitle == "A key that stays on your Mac.") diff --git a/Tests/ClavisAppTests/LibrarySidebarTests.swift b/Tests/ClavisAppTests/LibrarySidebarTests.swift new file mode 100644 index 0000000..7c5f995 --- /dev/null +++ b/Tests/ClavisAppTests/LibrarySidebarTests.swift @@ -0,0 +1,43 @@ +import AppKit +import SwiftUI +import Testing +@testable import Clavis +@testable import ClavisCore + +@Suite @MainActor +struct LibrarySidebarTests { + @Test func legacySigningOnlyDoesNotAdvertiseSSH() { + let presentation = LibraryKeyPresentation(purpose: .gitSigningOnly) + #expect(presentation.typeTitle == "Personal") + #expect(presentation.capabilities == ["SSHSIG"]) + } + + @Test func generalAndBotCapabilitiesRemainDistinctFromType() { + let personal = LibraryKeyPresentation(purpose: .general) + let bot = LibraryKeyPresentation(purpose: .agent) + #expect(personal.typeTitle == "Personal") + #expect(bot.typeTitle == "Bot") + #expect(personal.capabilities == bot.capabilities) + #expect(bot.accessibilitySummary == "Bot, SSH, SSHSIG") + } + + @Test func nativeSearchUpdatesBindingAndCancelClearsBeforeBlurring() { + var value = "github" + var focus = true + let view = LibrarySearchField( + text: Binding(get: { value }, set: { value = $0 }), + focused: Binding(get: { focus }, set: { focus = $0 }) + ) + let delegate = view.makeCoordinator() + let field = NSSearchField() + field.stringValue = "production" + delegate.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: field)) + #expect(value == "production") + let cancel = #selector(NSResponder.cancelOperation(_:)) + #expect(delegate.control(field, textView: NSTextView(), doCommandBy: cancel)) + #expect(value.isEmpty) + #expect(focus) + #expect(delegate.control(field, textView: NSTextView(), doCommandBy: cancel)) + #expect(!focus) + } +}