diff --git a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift index 2096e55a..7a9c7483 100644 --- a/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift +++ b/supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift @@ -13,6 +13,7 @@ final class SettingsWindowManager { @ObservationIgnored private var store: StoreOf? @ObservationIgnored private var ghosttyShortcuts: GhosttyShortcutManager? @ObservationIgnored private var commandKeyObserver: CommandKeyObserver? + @ObservationIgnored private var localEventMonitor: Any? @ObservationIgnored private var willCloseObserver: NSObjectProtocol? private init() {} @@ -76,6 +77,27 @@ final class SettingsWindowManager { window.makeKeyAndOrderFront(nil) settingsWindow = window + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak window] event in + guard let window, event.window === window else { return event } + if SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: event.modifierFlags, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) { + window.performClose(nil) + return nil + } + return event + } isOpen = true } } + +enum SettingsWindowKeyboardShortcutPolicy { + static func isCloseWindowShortcut( + modifierFlags: NSEvent.ModifierFlags, + charactersIgnoringModifiers: String? + ) -> Bool { + modifierFlags.intersection(.deviceIndependentFlagsMask) == .command + && charactersIgnoringModifiers == "w" + } +} diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 8b891f67..4e75eb9d 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -29,36 +29,53 @@ struct SettingsView: View { NavigationSplitView(columnVisibility: .constant(.all)) { VStack(spacing: 0) { - List(selection: $settingsStore.selection.sending(\.setSelection)) { - Label("General", systemImage: "gearshape") - .tag(SettingsSection.general) - Label("Notifications", systemImage: "bell") - .tag(SettingsSection.notifications) - Label("Shortcuts", systemImage: "keyboard") - .tag(SettingsSection.shortcuts) - Label("Worktree", systemImage: "archivebox") - .tag(SettingsSection.worktree) - Label("Updates", systemImage: "arrow.down.circle") - .tag(SettingsSection.updates) - Label("Advanced", systemImage: "gearshape.2") - .tag(SettingsSection.advanced) - Label("GitHub", systemImage: "arrow.triangle.branch") - .tag(SettingsSection.github) + ScrollViewReader { scrollProxy in + List(selection: $settingsStore.selection.sending(\.setSelection)) { + Label("General", systemImage: "gearshape") + .tag(SettingsSection.general) + .id(SettingsSection.general) + Label("Notifications", systemImage: "bell") + .tag(SettingsSection.notifications) + .id(SettingsSection.notifications) + Label("Shortcuts", systemImage: "keyboard") + .tag(SettingsSection.shortcuts) + .id(SettingsSection.shortcuts) + Label("Worktree", systemImage: "archivebox") + .tag(SettingsSection.worktree) + .id(SettingsSection.worktree) + Label("Updates", systemImage: "arrow.down.circle") + .tag(SettingsSection.updates) + .id(SettingsSection.updates) + Label("Advanced", systemImage: "gearshape.2") + .tag(SettingsSection.advanced) + .id(SettingsSection.advanced) + Label("GitHub", systemImage: "arrow.triangle.branch") + .tag(SettingsSection.github) + .id(SettingsSection.github) - Section("Repositories") { - ForEach(repositories) { repository in - RepoDisplayName( - fallbackName: repository.name, - customTitle: customTitles[repository.id] - ) - .tag(SettingsSection.repository(repository.id)) + Section("Repositories") { + ForEach(repositories) { repository in + let repositorySection = SettingsSection.repository(repository.id) + RepoDisplayName( + fallbackName: repository.name, + customTitle: customTitles[repository.id] + ) + .tag(repositorySection) + .id(repositorySection) + } } } + .listStyle(.sidebar) + .frame(minWidth: 220, maxHeight: .infinity) + .navigationSplitViewColumnWidth(220) + .removingSidebarToggle() + .onAppear { + scrollToRepositorySelection(settingsStore.selection, proxy: scrollProxy) + } + .onChange(of: settingsStore.selection) { _, selection in + scrollToRepositorySelection(selection, proxy: scrollProxy) + } } - .listStyle(.sidebar) - .frame(minWidth: 220, maxHeight: .infinity) - .navigationSplitViewColumnWidth(220) - .removingSidebarToggle() } } detail: { switch selection { @@ -135,4 +152,11 @@ struct SettingsView: View { } .ignoresSafeArea(.container, edges: .top) } + + private func scrollToRepositorySelection(_ selection: SettingsSection?, proxy: ScrollViewProxy) { + guard case .repository(let repositoryID) = selection else { return } + withAnimation { + proxy.scrollTo(SettingsSection.repository(repositoryID), anchor: .center) + } + } } diff --git a/supacodeTests/WindowCloseShortcutPolicyTests.swift b/supacodeTests/WindowCloseShortcutPolicyTests.swift index 2604dbf1..9a289f71 100644 --- a/supacodeTests/WindowCloseShortcutPolicyTests.swift +++ b/supacodeTests/WindowCloseShortcutPolicyTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import Testing @@ -86,3 +87,32 @@ struct WindowCloseShortcutPolicyTests { #expect(shortcut?.modifiers == .command) } } + +struct SettingsWindowShortcutPolicyTests { + @Test func commandWClosesSettingsWindow() { + #expect( + SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: .command, + charactersIgnoringModifiers: "w" + ) + ) + } + + @Test func modifiedCommandWDoesNotCloseSettingsWindow() { + #expect( + !SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: [.command, .shift], + charactersIgnoringModifiers: "w" + ) + ) + } + + @Test func commandOtherKeyDoesNotCloseSettingsWindow() { + #expect( + !SettingsWindowKeyboardShortcutPolicy.isCloseWindowShortcut( + modifierFlags: .command, + charactersIgnoringModifiers: "q" + ) + ) + } +}