native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #192 from supabitapp/sbertix/settings

authored by

khoi and committed by
GitHub
dd7b2d6d e89a4d35

+813 -925
+17
supacode.xcodeproj/project.pbxproj
··· 19 19 D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6A1CB342F1F4AC2004FDABD /* Carbon.framework */; }; 20 20 D6D054282F2E3EBF00D70ED5 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = D6D054272F2E3EBF00D70ED5 /* PostHog */; }; 21 21 DFE570C142184CC387C1BC78 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B5698573F7354B14B940EA60 /* Sparkle */; }; 22 + E0DF87932F79E2010033488C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = E0DF87922F79E2010033488C /* Kingfisher */; }; 22 23 /* End PBXBuildFile section */ 23 24 24 25 /* Begin PBXContainerItemProxy section */ ··· 75 76 C8B33B9AFE69E738ABFA08BE /* ComposableArchitecture in Frameworks */, 76 77 886A649F771240E8A5AC792D /* Dependencies in Frameworks */, 77 78 D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */, 79 + E0DF87932F79E2010033488C /* Kingfisher in Frameworks */, 78 80 D6D054282F2E3EBF00D70ED5 /* PostHog in Frameworks */, 79 81 D661760B2F250A60000FC27C /* CasePaths in Frameworks */, 80 82 D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */, ··· 152 154 D661760A2F250A60000FC27C /* CasePaths */, 153 155 8B2985744DB94333AE1B5878 /* Sentry */, 154 156 D6D054272F2E3EBF00D70ED5 /* PostHog */, 157 + E0DF87922F79E2010033488C /* Kingfisher */, 155 158 ); 156 159 productName = supacode; 157 160 productReference = D69CE04A2F1F378200584C57 /* supacode.app */; ··· 216 219 B43B9188E1D223E6F7344501 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, 217 220 899A0B6B6B42424B92CD20C2 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 218 221 D6D054262F2E3EB300D70ED5 /* XCRemoteSwiftPackageReference "posthog-ios" */, 222 + E0DF87912F79E2010033488C /* XCRemoteSwiftPackageReference "Kingfisher" */, 219 223 ); 220 224 preferredProjectObjectVersion = 77; 221 225 productRefGroup = D69CE04B2F1F378200584C57 /* Products */; ··· 663 667 minimumVersion = 3.38.0; 664 668 }; 665 669 }; 670 + E0DF87912F79E2010033488C /* XCRemoteSwiftPackageReference "Kingfisher" */ = { 671 + isa = XCRemoteSwiftPackageReference; 672 + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; 673 + requirement = { 674 + kind = upToNextMajorVersion; 675 + minimumVersion = 8.8.0; 676 + }; 677 + }; 666 678 /* End XCRemoteSwiftPackageReference section */ 667 679 668 680 /* Begin XCSwiftPackageProductDependency section */ ··· 700 712 isa = XCSwiftPackageProductDependency; 701 713 package = D6D054262F2E3EB300D70ED5 /* XCRemoteSwiftPackageReference "posthog-ios" */; 702 714 productName = PostHog; 715 + }; 716 + E0DF87922F79E2010033488C /* Kingfisher */ = { 717 + isa = XCSwiftPackageProductDependency; 718 + package = E0DF87912F79E2010033488C /* XCRemoteSwiftPackageReference "Kingfisher" */; 719 + productName = Kingfisher; 703 720 }; 704 721 /* End XCSwiftPackageProductDependency section */ 705 722 };
+10 -1
supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
··· 1 1 { 2 - "originHash" : "296c41175f338e41eab03bab9aa17a7cab425a0ac3a069540ffc2f8803489c5c", 2 + "originHash" : "20aee5b496aa5491fc11090e3e68ad6d7408e3708ff72c30f19521a249c6263c", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "combine-schedulers", ··· 8 8 "state" : { 9 9 "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", 10 10 "version" : "1.1.0" 11 + } 12 + }, 13 + { 14 + "identity" : "kingfisher", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/onevcat/Kingfisher.git", 17 + "state" : { 18 + "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", 19 + "version" : "8.8.0" 11 20 } 12 21 }, 13 22 {
+28 -6
supacode/App/GhosttyColorSchemeSyncView.swift
··· 1 + import AppKit 2 + import Sharing 1 3 import SwiftUI 2 4 5 + /// Synchronizes the user's appearance mode preference with both NSApp appearance 6 + /// and Ghostty's color scheme, and reloads Ghostty config when terminal theme sync is toggled. 3 7 struct GhosttyColorSchemeSyncView<Content: View>: View { 4 - @Environment(\.colorScheme) private var colorScheme 8 + @Environment(\.colorScheme) private var systemColorScheme 9 + @Shared(.settingsFile) private var settingsFile 5 10 let ghostty: GhosttyRuntime 6 11 let content: Content 7 12 ··· 11 16 } 12 17 13 18 var body: some View { 19 + let resolved = settingsFile.global.appearanceMode.resolved(systemColorScheme: systemColorScheme) 14 20 content 15 21 .task { 16 - apply(colorScheme) 22 + applyAppAppearance() 23 + ghostty.setColorScheme(resolved) 17 24 } 18 - .onChange(of: colorScheme) { _, newValue in 19 - apply(newValue) 25 + .onChange(of: settingsFile.global.appearanceMode) { 26 + applyAppAppearance() 27 + } 28 + .onChange(of: resolved) { _, newValue in 29 + ghostty.setColorScheme(newValue) 30 + } 31 + .onChange(of: settingsFile.global.terminalThemeSyncEnabled) { 32 + ghostty.reloadAppConfig() 20 33 } 21 34 } 22 35 23 - private func apply(_ scheme: ColorScheme) { 24 - ghostty.setColorScheme(scheme) 36 + private func applyAppAppearance() { 37 + let appearance: NSAppearance? = 38 + switch settingsFile.global.appearanceMode { 39 + case .system: nil 40 + case .light: NSAppearance(named: .aqua) 41 + case .dark: NSAppearance(named: .darkAqua) 42 + } 43 + NSApp.appearance = appearance 44 + for window in NSApp.windows { 45 + window.appearance = appearance 46 + } 25 47 } 26 48 }
+5
supacode/App/WindowID.swift
··· 1 + /// Identifiers for the app's SwiftUI `Window` scenes. 2 + enum WindowID { 3 + static let main = "main" 4 + static let settings = "settings" 5 + }
+1 -1
supacode/App/WindowTabbingDisabler.swift
··· 20 20 func disallowTabbing() { 21 21 guard let window else { return } 22 22 window.tabbingMode = .disallowed 23 - window.identifier = NSUserInterfaceItemIdentifier("main") 23 + window.identifier = NSUserInterfaceItemIdentifier(WindowID.main) 24 24 if window.delegate !== self { 25 25 window.delegate = self 26 26 }
+26 -30
supacode/App/supacodeApp.swift
··· 57 57 } 58 58 59 59 private func mainWindow(from sender: NSApplication) -> NSWindow? { 60 - if let window = sender.windows.first(where: { $0.identifier?.rawValue == "main" }) { 60 + if let window = sender.windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { 61 61 return window 62 62 } 63 - if let window = sender.windows.first(where: { $0.identifier?.rawValue != "settings" }) { 63 + if let window = sender.windows.first(where: { $0.identifier?.rawValue != WindowID.settings }) { 64 64 return window 65 65 } 66 66 return sender.windows.first ··· 157 157 } 158 158 _store = State(initialValue: appStore) 159 159 appDelegate.appStore = appStore 160 - SettingsWindowManager.shared.configure( 161 - store: appStore, 162 - ghosttyShortcuts: shortcuts, 163 - commandKeyObserver: keyObserver 164 - ) 165 160 } 166 161 167 162 var body: some Scene { 168 - Window("Supacode", id: "main") { 163 + Window("Supacode", id: WindowID.main) { 169 164 GhosttyColorSchemeSyncView(ghostty: ghostty) { 170 165 ContentView(store: store, terminalManager: terminalManager) 171 166 .environment(ghosttyShortcuts) 172 167 .environment(commandKeyObserver) 173 168 } 174 - .preferredColorScheme(store.settings.appearanceMode.colorScheme) 169 + .openSettingsOnSelection(store: store) 175 170 } 176 171 .environment(ghosttyShortcuts) 177 172 .environment(commandKeyObserver) ··· 189 184 .help("Command Palette (\(cmdPalette?.display ?? "none"))") 190 185 } 191 186 UpdateCommands(store: store.scope(state: \.updates, action: \.updates)) 192 - CommandGroup(replacing: .windowArrangement) { 193 - Button("Supacode") { 194 - if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { 195 - window.makeKeyAndOrderFront(nil) 196 - NSApp.activate(ignoringOtherApps: true) 187 + Group { 188 + CommandGroup(replacing: .windowList) {} 189 + CommandGroup(replacing: .singleWindowList) { 190 + Button("Supacode") { 191 + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { 192 + window.makeKeyAndOrderFront(nil) 193 + NSApp.activate(ignoringOtherApps: true) 194 + } 197 195 } 198 - } 199 - .keyboardShortcut("0") 200 - .help("Show main window (⌘0)") 201 - Divider() 202 - Button("Minimize") { 203 - NSApp.keyWindow?.miniaturize(nil) 196 + .keyboardShortcut("0") 197 + .help("Show main window (⌘0)") 204 198 } 205 - .keyboardShortcut("m") 206 - .help("Minimize (⌘M)") 207 - Button("Zoom") { 208 - NSApp.keyWindow?.zoom(nil) 209 - } 210 - .help("Zoom (no shortcut)") 211 199 } 212 200 CommandGroup(replacing: .appSettings) { 213 - let settings = AppShortcuts.openSettings.effective(from: store.settings.shortcutOverrides) 214 - Button("Settings...") { 215 - SettingsWindowManager.shared.show() 201 + SettingsMenuButton(shortcutOverrides: store.settings.shortcutOverrides) { 202 + store.send(.settings(.setSelection(.general))) 216 203 } 217 - .appKeyboardShortcut(settings) 218 204 } 219 205 CommandGroup(replacing: .help) { 220 206 Button("Submit GitHub Issue") { ··· 231 217 .help("Quit Supacode (⌘Q)") 232 218 } 233 219 } 220 + Window("Settings", id: WindowID.settings) { 221 + SettingsView(store: store) 222 + .environment(ghosttyShortcuts) 223 + .environment(commandKeyObserver) 224 + .toolbarBackground(.hidden, for: .windowToolbar) 225 + .toolbarColorScheme(store.settings.appearanceMode.colorScheme, for: .windowToolbar) 226 + } 227 + .windowToolbarStyle(.unified) 228 + .defaultSize(width: 800, height: 600) 229 + .restorationBehavior(.disabled) 234 230 } 235 231 }
supacode/Assets.xcassets/AppearanceAuto.imageset/AppearanceAuto.png

This is a binary file and will not be displayed.

supacode/Assets.xcassets/AppearanceAuto.imageset/AppearanceAuto@2x.png

This is a binary file and will not be displayed.

+22
supacode/Assets.xcassets/AppearanceAuto.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "AppearanceAuto.png", 5 + "idiom" : "universal", 6 + "scale" : "1x" 7 + }, 8 + { 9 + "filename" : "AppearanceAuto@2x.png", 10 + "idiom" : "universal", 11 + "scale" : "2x" 12 + }, 13 + { 14 + "idiom" : "universal", 15 + "scale" : "3x" 16 + } 17 + ], 18 + "info" : { 19 + "author" : "xcode", 20 + "version" : 1 21 + } 22 + }
supacode/Assets.xcassets/AppearanceDark.imageset/AppearanceDark.png

This is a binary file and will not be displayed.

supacode/Assets.xcassets/AppearanceDark.imageset/AppearanceDark@2x.png

This is a binary file and will not be displayed.

+22
supacode/Assets.xcassets/AppearanceDark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "AppearanceDark.png", 5 + "idiom" : "universal", 6 + "scale" : "1x" 7 + }, 8 + { 9 + "filename" : "AppearanceDark@2x.png", 10 + "idiom" : "universal", 11 + "scale" : "2x" 12 + }, 13 + { 14 + "idiom" : "universal", 15 + "scale" : "3x" 16 + } 17 + ], 18 + "info" : { 19 + "author" : "xcode", 20 + "version" : 1 21 + } 22 + }
supacode/Assets.xcassets/AppearanceLight.imageset/AppearanceLight.png

This is a binary file and will not be displayed.

supacode/Assets.xcassets/AppearanceLight.imageset/AppearanceLight@2x.png

This is a binary file and will not be displayed.

+22
supacode/Assets.xcassets/AppearanceLight.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "AppearanceLight.png", 5 + "idiom" : "universal", 6 + "scale" : "1x" 7 + }, 8 + { 9 + "filename" : "AppearanceLight@2x.png", 10 + "idiom" : "universal", 11 + "scale" : "2x" 12 + }, 13 + { 14 + "idiom" : "universal", 15 + "scale" : "3x" 16 + } 17 + ], 18 + "info" : { 19 + "author" : "xcode", 20 + "version" : 1 21 + } 22 + }
+16
supacode/Assets.xcassets/github-mark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "github-mark.svg", 5 + "idiom" : "universal" 6 + } 7 + ], 8 + "info" : { 9 + "author" : "xcode", 10 + "version" : 1 11 + }, 12 + "properties" : { 13 + "preserves-vector-representation" : true, 14 + "template-rendering-intent" : "template" 15 + } 16 + }
+1
supacode/Assets.xcassets/github-mark.imageset/github-mark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 17 17" width="15" height="15"><path fill="black" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>
-20
supacode/Clients/SettingsWindow/SettingsWindowClient.swift
··· 1 - import ComposableArchitecture 2 - 3 - struct SettingsWindowClient { 4 - var show: @MainActor @Sendable () -> Void 5 - } 6 - 7 - extension SettingsWindowClient: DependencyKey { 8 - static let liveValue = SettingsWindowClient { 9 - SettingsWindowManager.shared.show() 10 - } 11 - 12 - static let testValue = SettingsWindowClient {} 13 - } 14 - 15 - extension DependencyValues { 16 - var settingsWindowClient: SettingsWindowClient { 17 - get { self[SettingsWindowClient.self] } 18 - set { self[SettingsWindowClient.self] = newValue } 19 - } 20 - }
+5 -15
supacode/Features/App/Reducer/AppFeature.swift
··· 73 73 @Dependency(AnalyticsClient.self) private var analyticsClient 74 74 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 75 75 @Dependency(WorkspaceClient.self) private var workspaceClient 76 - @Dependency(SettingsWindowClient.self) private var settingsWindowClient 77 76 @Dependency(NotificationSoundClient.self) private var notificationSoundClient 78 77 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 79 78 @Dependency(TerminalClient.self) private var terminalClient ··· 196 195 !repositories.contains(where: { $0.id == repositoryID }) 197 196 { 198 197 return .merge( 198 + .send(.settings(.repositoriesChanged(repositories))), 199 199 .send(.settings(.setSelection(.general))), 200 200 .send(.commandPalette(.pruneRecency(recencyIDs))), 201 201 .run { _ in ··· 207 207 ) 208 208 } 209 209 return .merge( 210 + .send(.settings(.repositoriesChanged(repositories))), 210 211 .send(.commandPalette(.pruneRecency(recencyIDs))), 211 212 .run { _ in 212 213 await terminalClient.send(.prune(ids)) ··· 220 221 guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { 221 222 return .none 222 223 } 223 - let selection = SettingsSection.repository(repositoryID) 224 - return .merge( 225 - .send(.settings(.setSelection(selection))), 226 - .run { _ in 227 - await settingsWindowClient.show() 228 - } 229 - ) 224 + return .send(.settings(.setSelection(.repository(repositoryID)))) 230 225 231 226 case .repositories(.delegate(.runBlockingScript(let worktree, _, let kind, let script))): 232 227 return .run { _ in ··· 246 241 rootURL: repository.rootURL, 247 242 settings: repositorySettings 248 243 ) 249 - case .general, .notifications, .worktree, .shortcuts, .updates, .advanced, .github: 244 + case .general, .notifications, .worktree, .shortcuts, .updates, .github: 250 245 state.settings.repositorySettings = nil 251 246 } 252 247 return .none ··· 581 576 return .send(.updates(.checkForUpdates)) 582 577 583 578 case .commandPalette(.delegate(.openSettings)): 584 - return .merge( 585 - .send(.settings(.setSelection(.general))), 586 - .run { _ in 587 - await settingsWindowClient.show() 588 - } 589 - ) 579 + return .send(.settings(.setSelection(.general))) 590 580 591 581 case .commandPalette(.delegate(.newWorktree)): 592 582 return .send(.repositories(.createRandomWorktree))
+1
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 155 155 ) 156 156 .id(selectedWorktree.id) 157 157 .frame(maxWidth: .infinity, maxHeight: .infinity) 158 + .ignoresSafeArea(.container, edges: .bottom) 158 159 .onAppear { 159 160 if shouldFocusTerminal { 160 161 store.send(.repositories(.consumeTerminalFocus(selectedWorktree.id)))
+1 -1
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 317 317 if showsNotificationIndicator { 318 318 NotificationPopoverButton(notifications: notifications) { 319 319 Circle() 320 - .fill(.red) 320 + .fill(.orange) 321 321 .frame(width: 6, height: 6) 322 322 .accessibilityLabel("Unread notifications") 323 323 }
+2 -1
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 17 17 SupacodePaths.exampleWorktreePath( 18 18 for: rootURL, 19 19 globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 20 - repositoryOverridePath: settings.worktreeBaseDirectoryPath 20 + repositoryOverridePath: settings.worktreeBaseDirectoryPath, 21 + branchName: "**/*" 21 22 ) 22 23 } 23 24 }
-64
supacode/Features/Settings/BusinessLogic/SettingsWindowManager.swift
··· 1 - import AppKit 2 - import ComposableArchitecture 3 - import SwiftUI 4 - 5 - @MainActor 6 - final class SettingsWindowManager { 7 - static let shared = SettingsWindowManager() 8 - 9 - private var settingsWindow: NSWindow? 10 - private var store: StoreOf<AppFeature>? 11 - private var ghosttyShortcuts: GhosttyShortcutManager? 12 - private var commandKeyObserver: CommandKeyObserver? 13 - 14 - private init() {} 15 - 16 - func configure( 17 - store: StoreOf<AppFeature>, 18 - ghosttyShortcuts: GhosttyShortcutManager, 19 - commandKeyObserver: CommandKeyObserver 20 - ) { 21 - self.store = store 22 - self.ghosttyShortcuts = ghosttyShortcuts 23 - self.commandKeyObserver = commandKeyObserver 24 - } 25 - 26 - func show() { 27 - if let existingWindow = settingsWindow { 28 - if existingWindow.isMiniaturized { 29 - existingWindow.deminiaturize(nil) 30 - } 31 - existingWindow.makeKeyAndOrderFront(nil) 32 - return 33 - } 34 - 35 - guard let store, let ghosttyShortcuts, let commandKeyObserver else { 36 - return 37 - } 38 - let settingsView = SettingsView(store: store) 39 - .environment(ghosttyShortcuts) 40 - .environment(commandKeyObserver) 41 - let hostingController = NSHostingController(rootView: settingsView) 42 - 43 - let window = NSWindow(contentViewController: hostingController) 44 - window.title = "" 45 - window.titleVisibility = .hidden 46 - window.identifier = NSUserInterfaceItemIdentifier("settings") 47 - window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] 48 - window.tabbingMode = .disallowed 49 - window.titlebarAppearsTransparent = true 50 - window.toolbarStyle = .unified 51 - window.toolbar = NSToolbar(identifier: "SettingsToolbar") 52 - if #unavailable(macOS 15.0) { 53 - window.toolbar?.showsBaselineSeparator = false 54 - } 55 - window.isReleasedWhenClosed = false 56 - window.setContentSize(NSSize(width: 800, height: 600)) 57 - window.minSize = NSSize(width: 750, height: 500) 58 - 59 - window.center() 60 - window.makeKeyAndOrderFront(nil) 61 - 62 - settingsWindow = window 63 - } 64 - }
-48
supacode/Features/Settings/BusinessLogic/WindowAppearanceSetter.swift
··· 1 - import AppKit 2 - import SwiftUI 3 - 4 - struct WindowAppearanceSetter: NSViewRepresentable { 5 - let colorScheme: ColorScheme? 6 - 7 - func makeNSView(context: Context) -> WindowAppearanceView { 8 - let view = WindowAppearanceView() 9 - view.colorScheme = colorScheme 10 - return view 11 - } 12 - 13 - func updateNSView(_ nsView: WindowAppearanceView, context: Context) { 14 - nsView.colorScheme = colorScheme 15 - } 16 - } 17 - 18 - final class WindowAppearanceView: NSView { 19 - var colorScheme: ColorScheme? { 20 - didSet { 21 - applyAppearance() 22 - } 23 - } 24 - 25 - override func viewDidMoveToWindow() { 26 - super.viewDidMoveToWindow() 27 - applyAppearance() 28 - } 29 - 30 - private func applyAppearance() { 31 - guard let window else { 32 - return 33 - } 34 - switch colorScheme { 35 - case .none: 36 - window.appearance = nil 37 - case .some(let scheme): 38 - switch scheme { 39 - case .light: 40 - window.appearance = NSAppearance(named: .aqua) 41 - case .dark: 42 - window.appearance = NSAppearance(named: .darkAqua) 43 - @unknown default: 44 - window.appearance = nil 45 - } 46 - } 47 - } 48 - }
-29
supacode/Features/Settings/BusinessLogic/WindowLevelSetter.swift
··· 1 - import AppKit 2 - import SwiftUI 3 - 4 - struct WindowLevelSetter: NSViewRepresentable { 5 - let level: NSWindow.Level 6 - 7 - func makeNSView(context: Context) -> WindowLevelView { 8 - let view = WindowLevelView() 9 - view.level = level 10 - return view 11 - } 12 - 13 - func updateNSView(_ nsView: WindowLevelView, context: Context) { 14 - nsView.level = level 15 - } 16 - } 17 - 18 - final class WindowLevelView: NSView { 19 - var level: NSWindow.Level = .normal { 20 - didSet { 21 - window?.level = level 22 - } 23 - } 24 - 25 - override func viewDidMoveToWindow() { 26 - super.viewDidMoveToWindow() 27 - window?.level = level 28 - } 29 - }
+12 -34
supacode/Features/Settings/Models/AppearanceMode.swift
··· 12 12 var title: String { 13 13 switch self { 14 14 case .system: 15 - return "System" 15 + return "Auto" 16 16 case .light: 17 17 return "Light" 18 18 case .dark: ··· 20 20 } 21 21 } 22 22 23 - var colorScheme: ColorScheme? { 24 - switch self { 25 - case .system: 26 - return nil 27 - case .light: 28 - return .light 29 - case .dark: 30 - return .dark 31 - } 32 - } 33 - 34 - var previewBackground: Color { 35 - switch self { 36 - case .system: 37 - return Color(nsColor: .windowBackgroundColor) 38 - case .light: 39 - return .white 40 - case .dark: 41 - return .black 42 - } 43 - } 44 - 45 - var previewPrimary: Color { 23 + var imageName: String { 46 24 switch self { 47 25 case .system: 48 - return .primary.opacity(0.2) 26 + return "AppearanceAuto" 49 27 case .light: 50 - return .black.opacity(0.15) 28 + return "AppearanceLight" 51 29 case .dark: 52 - return .white.opacity(0.2) 30 + return "AppearanceDark" 53 31 } 54 32 } 55 33 56 - var previewSecondary: Color { 34 + var colorScheme: ColorScheme? { 57 35 switch self { 58 36 case .system: 59 - return .primary.opacity(0.12) 37 + return nil 60 38 case .light: 61 - return .black.opacity(0.08) 39 + return .light 62 40 case .dark: 63 - return .white.opacity(0.12) 41 + return .dark 64 42 } 65 43 } 66 44 67 - var previewAccent: Color { 68 - .blue 45 + /// Resolves the color scheme, falling back to the system color scheme for `.system`. 46 + func resolved(systemColorScheme: ColorScheme) -> ColorScheme { 47 + colorScheme ?? systemColorScheme 69 48 } 70 - 71 49 }
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 16 16 var automaticallyArchiveMergedWorktrees: Bool 17 17 var promptForWorktreeCreation: Bool 18 18 var defaultWorktreeBaseDirectoryPath: String? 19 + var terminalThemeSyncEnabled: Bool 19 20 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 20 21 21 22 static let `default` = GlobalSettings( ··· 35 36 deleteBranchOnDeleteWorktree: true, 36 37 automaticallyArchiveMergedWorktrees: false, 37 38 promptForWorktreeCreation: true, 39 + terminalThemeSyncEnabled: false, 38 40 defaultWorktreeBaseDirectoryPath: nil, 39 41 shortcutOverrides: [:] 40 42 ) ··· 56 58 deleteBranchOnDeleteWorktree: Bool, 57 59 automaticallyArchiveMergedWorktrees: Bool, 58 60 promptForWorktreeCreation: Bool, 61 + terminalThemeSyncEnabled: Bool = false, 59 62 defaultWorktreeBaseDirectoryPath: String? = nil, 60 63 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] 61 64 ) { ··· 75 78 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 76 79 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 77 80 self.promptForWorktreeCreation = promptForWorktreeCreation 81 + self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 78 82 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 79 83 self.shortcutOverrides = shortcutOverrides 80 84 } ··· 123 127 promptForWorktreeCreation = 124 128 try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 125 129 ?? Self.default.promptForWorktreeCreation 130 + terminalThemeSyncEnabled = 131 + try container.decodeIfPresent(Bool.self, forKey: .terminalThemeSyncEnabled) 132 + ?? Self.default.terminalThemeSyncEnabled 126 133 defaultWorktreeBaseDirectoryPath = 127 134 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 128 135 ?? Self.default.defaultWorktreeBaseDirectoryPath
+18 -2
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 1 1 import ComposableArchitecture 2 2 import Foundation 3 + import IdentifiedCollections 3 4 4 5 @Reducer 5 6 struct SettingsFeature { ··· 21 22 var deleteBranchOnDeleteWorktree: Bool 22 23 var automaticallyArchiveMergedWorktrees: Bool 23 24 var promptForWorktreeCreation: Bool 25 + var terminalThemeSyncEnabled: Bool 24 26 var defaultWorktreeBaseDirectoryPath: String 25 27 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 26 - var selection: SettingsSection? = .general 28 + // nil = settings window closed, non-nil = open to this section. 29 + // The view layer opens the settings window when this becomes non-nil. 30 + var selection: SettingsSection? 31 + var sortedRepositoryIDs: [Repository.ID] = [] 27 32 var repositorySettings: RepositorySettingsFeature.State? 28 33 @Presents var alert: AlertState<Alert>? 29 34 ··· 45 50 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 46 51 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 47 52 promptForWorktreeCreation = settings.promptForWorktreeCreation 53 + terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 48 54 shortcutOverrides = settings.shortcutOverrides 49 55 defaultWorktreeBaseDirectoryPath = 50 56 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" ··· 68 74 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 69 75 automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 70 76 promptForWorktreeCreation: promptForWorktreeCreation, 77 + terminalThemeSyncEnabled: terminalThemeSyncEnabled, 71 78 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 72 79 defaultWorktreeBaseDirectoryPath 73 80 ), ··· 79 86 enum Action: BindableAction { 80 87 case task 81 88 case settingsLoaded(GlobalSettings) 89 + case repositoriesChanged(IdentifiedArrayOf<Repository>) 82 90 case setSelection(SettingsSection?) 83 91 case setSystemNotificationsEnabled(Bool) 84 92 case showNotificationPermissionAlert(errorMessage: String?) ··· 145 153 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 146 154 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 147 155 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 156 + state.terminalThemeSyncEnabled = normalizedSettings.terminalThemeSyncEnabled 148 157 state.shortcutOverrides = normalizedSettings.shortcutOverrides 149 158 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 150 159 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = ··· 221 230 state.shortcutOverrides = [:] 222 231 return persist(state) 223 232 233 + case .repositoriesChanged(let repositories): 234 + state.sortedRepositoryIDs = 235 + repositories 236 + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } 237 + .map(\.id) 238 + return .none 239 + 224 240 case .setSelection(let selection): 225 - state.selection = selection ?? .general 241 + state.selection = selection 226 242 return .none 227 243 228 244 case .alert(.dismiss):
-45
supacode/Features/Settings/Views/AdvancedSettingsView.swift
··· 1 - import ComposableArchitecture 2 - import SwiftUI 3 - 4 - struct AdvancedSettingsView: View { 5 - @Bindable var store: StoreOf<SettingsFeature> 6 - 7 - var body: some View { 8 - VStack(alignment: .leading) { 9 - Form { 10 - Section("Advanced") { 11 - VStack(alignment: .leading) { 12 - Toggle( 13 - "Share analytics with Supacode", 14 - isOn: $store.analyticsEnabled 15 - ) 16 - .help("Share anonymous usage data with Supacode (requires restart)") 17 - Text("Anonymous usage data helps improve Supacode.") 18 - .foregroundStyle(.secondary) 19 - .font(.callout) 20 - Text("Requires app restart.") 21 - .foregroundStyle(.secondary) 22 - .font(.callout) 23 - } 24 - .frame(maxWidth: .infinity, alignment: .leading) 25 - VStack(alignment: .leading) { 26 - Toggle( 27 - "Share crash reports with Supacode", 28 - isOn: $store.crashReportsEnabled 29 - ) 30 - .help("Share anonymous crash reports with Supacode (requires restart)") 31 - Text("Anonymous crash reports help improve stability.") 32 - .foregroundStyle(.secondary) 33 - .font(.callout) 34 - Text("Requires app restart.") 35 - .foregroundStyle(.secondary) 36 - .font(.callout) 37 - } 38 - .frame(maxWidth: .infinity, alignment: .leading) 39 - } 40 - } 41 - .formStyle(.grouped) 42 - } 43 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 44 - } 45 - }
+14 -39
supacode/Features/Settings/Views/AppearanceOptionCardView.swift
··· 6 6 let action: () -> Void 7 7 8 8 var body: some View { 9 - let strokeColor = isSelected ? Color.accentColor : Color.secondary.opacity(0.35) 10 - 11 9 Button(action: action) { 12 - VStack { 13 - ZStack { 14 - RoundedRectangle(cornerRadius: 12) 15 - .fill(mode.previewBackground) 16 - VStack(alignment: .leading) { 17 - HStack { 18 - Circle() 19 - .fill(.red) 20 - .frame(width: 6, height: 6) 21 - Circle() 22 - .fill(.yellow) 23 - .frame(width: 6, height: 6) 24 - Circle() 25 - .fill(.green) 26 - .frame(width: 6, height: 6) 27 - } 28 - RoundedRectangle(cornerRadius: 3) 29 - .fill(mode.previewPrimary) 30 - .frame(height: 10) 31 - RoundedRectangle(cornerRadius: 3) 32 - .fill(mode.previewSecondary) 33 - .frame(height: 8) 34 - RoundedRectangle(cornerRadius: 3) 35 - .fill(mode.previewAccent) 36 - .frame(width: 64, height: 6) 10 + VStack(spacing: 4) { 11 + Image(mode.imageName) 12 + .resizable() 13 + .aspectRatio(contentMode: .fit) 14 + .clipShape(.rect(cornerRadius: 8)) 15 + .accessibilityLabel(mode.title) 16 + .overlay { 17 + RoundedRectangle(cornerRadius: 8) 18 + .strokeBorder( 19 + isSelected ? Color.accentColor : .clear, 20 + lineWidth: 2 21 + ) 37 22 } 38 - .padding() 39 - } 40 - .aspectRatio(1.6, contentMode: .fit) 41 23 Text(mode.title) 42 - .font(.headline) 24 + .font(.callout) 25 + .foregroundStyle(isSelected ? .primary : .secondary) 43 26 } 44 - .frame(maxWidth: .infinity) 45 - .padding() 46 27 } 47 28 .buttonStyle(.plain) 48 - .background(isSelected ? Color.accentColor.opacity(0.12) : .clear) 49 - .clipShape(.rect(cornerRadius: 12)) 50 - .overlay { 51 - RoundedRectangle(cornerRadius: 12) 52 - .stroke(strokeColor, lineWidth: isSelected ? 2 : 1) 53 - } 54 29 } 55 30 }
+51 -28
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 6 6 7 7 var body: some View { 8 8 let openActionOptions = OpenWorktreeAction.availableCases 9 - VStack(alignment: .leading) { 10 - Form { 11 - Section("Appearance") { 12 - HStack { 9 + Form { 10 + Section { 11 + LabeledContent("Appearance") { 12 + HStack(spacing: 12) { 13 13 let appearanceMode = $store.appearanceMode 14 14 ForEach(AppearanceMode.allCases) { mode in 15 15 AppearanceOptionCardView( ··· 20 20 } 21 21 } 22 22 } 23 + } 24 + Toggle(isOn: $store.terminalThemeSyncEnabled) { 25 + Text("Sync with Terminal") 26 + Text("Applies the appearance-aware Supacode color palette.") 27 + } 28 + if !store.terminalThemeSyncEnabled { 23 29 VStack(alignment: .leading, spacing: 4) { 24 - Text("Terminal theming follows Ghostty config") 25 - Text("For example, add the following line to `~/.config/ghostty/config`") 26 - Text("theme = light:Monokai Pro Light Sun,dark:Dimmed Monokai") 27 - .monospaced() 30 + Text("Add a theme to `~/.config/ghostty/config`") 31 + Text("e.g. `theme = light:Monokai Pro Light Sun,dark:Dimmed Monokai`") 28 32 } 29 33 .font(.footnote) 30 34 .foregroundStyle(.secondary) 31 35 .textSelection(.enabled) 32 36 } 33 - Section("Default Editor") { 34 - Picker( 35 - "Default editor", 36 - selection: $store.defaultEditorID 37 - ) { 38 - Text("Automatic") 39 - .tag(OpenWorktreeAction.automaticSettingsID) 40 - ForEach(openActionOptions) { action in 41 - Text(action.labelTitle) 42 - .tag(action.settingsID) 43 - } 37 + } 38 + Section { 39 + Toggle( 40 + "Confirm before Quitting", 41 + isOn: $store.confirmBeforeQuit 42 + ) 43 + .help("Ask before quitting Supacode") 44 + } 45 + Section("Editor") { 46 + Picker( 47 + selection: $store.defaultEditorID 48 + ) { 49 + Text("Automatic") 50 + .tag(OpenWorktreeAction.automaticSettingsID) 51 + ForEach(openActionOptions) { action in 52 + Text(action.labelTitle) 53 + .tag(action.settingsID) 44 54 } 45 - .help("Applies to worktrees without repository overrides.") 55 + } label: { 56 + Text("Default Editor") 57 + Text("Applies to Worktrees without repository overrides.") 46 58 } 47 - Section("Quit") { 48 - Toggle( 49 - "Confirm before quitting", 50 - isOn: $store.confirmBeforeQuit 51 - ) 52 - .help("Ask before quitting Supacode") 59 + } 60 + Section { 61 + Toggle(isOn: $store.analyticsEnabled) { 62 + Text("Share Analytics") 63 + Text("Anonymous usage data helps improve Supacode.") 64 + } 65 + Toggle(isOn: $store.crashReportsEnabled) { 66 + Text("Share Crash Reports") 67 + Text("Anonymous crash reports help improve stability.") 53 68 } 69 + } header: { 70 + Text("Analytics") 71 + } footer: { 72 + Text("Changes to Analytics require Supacode to restart before they take effect.") 54 73 } 55 - .formStyle(.grouped) 56 74 } 57 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 75 + .formStyle(.grouped) 76 + .padding(.top, -20) 77 + .padding(.leading, -8) 78 + .padding(.trailing, -6) 79 + 80 + .navigationTitle("General") 58 81 } 59 82 }
+67 -62
supacode/Features/Settings/Views/GithubSettingsView.swift
··· 54 54 @State private var viewModel = GithubSettingsViewModel() 55 55 56 56 var body: some View { 57 - VStack(alignment: .leading, spacing: 0) { 58 - Form { 59 - Section("GitHub integration") { 60 - Toggle( 61 - "Enable GitHub integration", 62 - isOn: $store.githubIntegrationEnabled 63 - ) 64 - .help("Enable GitHub integration") 57 + Form { 58 + Section { 59 + Toggle(isOn: $store.githubIntegrationEnabled) { 60 + Text("Enable GitHub Integration") 61 + Text("Pull request checks and merge actions in the command palette.") 65 62 } 66 - Section("GitHub CLI") { 67 - switch viewModel.state { 68 - case .loading: 69 - HStack(spacing: 8) { 70 - ProgressView() 71 - .controlSize(.small) 72 - Text("Checking GitHub CLI...") 73 - .foregroundStyle(.secondary) 74 - } 63 + } 64 + Section("GitHub CLI") { 65 + switch viewModel.state { 66 + case .loading: 67 + LabeledContent("Checking GitHub CLI…") { 68 + ProgressView().controlSize(.small) 69 + } 75 70 76 - case .unavailable: 77 - VStack(alignment: .leading, spacing: 8) { 78 - Label("GitHub integration unavailable", systemImage: "xmark.circle") 79 - .foregroundStyle(.red) 80 - Text("Enable GitHub integration and install gh CLI to use pull request checks.") 71 + case .unavailable: 72 + Label { 73 + VStack(alignment: .leading, spacing: 2) { 74 + Text("GitHub CLI not found") 75 + Text("Install `gh` to enable pull request checks.") 81 76 .foregroundStyle(.secondary) 82 77 .font(.callout) 83 78 } 79 + } icon: { 80 + Image(systemName: "xmark.circle") 81 + .foregroundStyle(.red) 82 + .accessibilityHidden(true) 83 + } 84 84 85 - case .notAuthenticated: 86 - VStack(alignment: .leading, spacing: 8) { 87 - Label("Not authenticated", systemImage: "exclamationmark.triangle") 88 - .foregroundStyle(.orange) 89 - Text("Run `gh auth login` in terminal to authenticate.") 85 + case .notAuthenticated: 86 + Label { 87 + VStack(alignment: .leading, spacing: 2) { 88 + Text("Not authenticated") 89 + Text("Run `gh auth login` in a terminal to authenticate.") 90 90 .foregroundStyle(.secondary) 91 91 .font(.callout) 92 92 } 93 + } icon: { 94 + Image(systemName: "exclamationmark.triangle") 95 + .foregroundStyle(.orange) 96 + .accessibilityHidden(true) 97 + } 93 98 94 - case .outdated: 95 - VStack(alignment: .leading, spacing: 8) { 96 - Label("GitHub CLI outdated", systemImage: "exclamationmark.triangle") 97 - .foregroundStyle(.orange) 98 - Text("Update GitHub CLI to the latest version to use GitHub integration.") 99 + case .outdated: 100 + Label { 101 + VStack(alignment: .leading, spacing: 2) { 102 + Text("GitHub CLI outdated") 103 + Text("Update to the latest version for full support.") 99 104 .foregroundStyle(.secondary) 100 105 .font(.callout) 101 106 } 107 + } icon: { 108 + Image(systemName: "exclamationmark.triangle") 109 + .foregroundStyle(.orange) 110 + .accessibilityHidden(true) 111 + } 102 112 103 - case .authenticated(let username, let host): 104 - LabeledContent("Signed in as") { 105 - Text(username) 106 - .font(.body) 107 - } 108 - LabeledContent("Host") { 109 - Text(host) 110 - .font(.body) 111 - } 113 + case .authenticated(let username, let host): 114 + LabeledContent("Signed in as") { 115 + Text(username) 116 + } 117 + LabeledContent("Host") { 118 + Text(host) 119 + } 112 120 113 - case .error(let message): 114 - VStack(alignment: .leading, spacing: 8) { 115 - Label("Error checking status", systemImage: "exclamationmark.triangle") 116 - .foregroundStyle(.red) 121 + case .error(let message): 122 + Label { 123 + VStack(alignment: .leading, spacing: 2) { 124 + Text("Error checking status") 117 125 Text(message) 118 126 .foregroundStyle(.secondary) 119 127 .font(.callout) 120 128 } 129 + } icon: { 130 + Image(systemName: "exclamationmark.triangle") 131 + .foregroundStyle(.red) 132 + .accessibilityHidden(true) 121 133 } 122 134 } 123 - } 124 - .formStyle(.grouped) 125 135 126 - switch viewModel.state { 127 - case .unavailable: 128 - HStack { 136 + switch viewModel.state { 137 + case .unavailable: 129 138 Button("Get GitHub CLI") { 130 139 NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) 131 140 } 132 - .help("Open GitHub CLI website") 133 - Spacer() 134 - } 135 - .padding(.top) 136 - case .outdated: 137 - HStack { 141 + case .outdated: 138 142 Button("Update GitHub CLI") { 139 143 NSWorkspace.shared.open(URL(string: "https://cli.github.com")!) 140 144 } 141 - .help("Open GitHub CLI website") 142 - Spacer() 145 + default: 146 + EmptyView() 143 147 } 144 - .padding(.top) 145 - default: 146 - EmptyView() 147 148 } 148 149 } 149 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 150 + .formStyle(.grouped) 151 + .padding(.top, -20) 152 + .padding(.leading, -8) 153 + .padding(.trailing, -6) 154 + .navigationTitle("GitHub") 150 155 .task { 151 156 await viewModel.load() 152 157 }
+2 -54
supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift
··· 106 106 } 107 107 } 108 108 .alternatingRowBackgrounds() 109 + .padding(.leading, -6) 109 110 .searchable(text: $searchText, placement: .toolbar, prompt: "Search...") 111 + .navigationTitle("Shortcuts") 110 112 .toolbar { 111 113 ToolbarItem(placement: .primaryAction) { 112 114 Button { ··· 128 130 } 129 131 } 130 132 } 131 - // Align window toolbar and first column text. 132 - .padding(.leading, -6) 133 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 134 - .overlay(alignment: .top) { 135 - // Liquid Glass removes the toolbar separator in windowed mode and `NSTitlebarSeparatorStyle` 136 - // has no effect, so we draw a manual divider when not in fullscreen. 137 - ToolbarSeparatorOverlay() 138 - } 139 - } 140 - } 141 - 142 - // MARK: - Toolbar separator. 143 - 144 - private struct ToolbarSeparatorOverlay: NSViewRepresentable { 145 - func makeNSView(context: Context) -> ToolbarSeparatorView { ToolbarSeparatorView() } 146 - func updateNSView(_ nsView: ToolbarSeparatorView, context: Context) {} 147 - } 148 - 149 - // Observes fullscreen transitions and hides the separator when the system already provides one. 150 - private final class ToolbarSeparatorView: NSView { 151 - private let separator = NSBox() 152 - 153 - override var acceptsFirstResponder: Bool { false } 154 - override func hitTest(_ point: NSPoint) -> NSView? { nil } 155 - 156 - override init(frame: NSRect) { 157 - super.init(frame: frame) 158 - separator.boxType = .separator 159 - addSubview(separator) 160 - } 161 - 162 - @available(*, unavailable) 163 - required init?(coder: NSCoder) { fatalError() } 164 - 165 - override func layout() { 166 - super.layout() 167 - separator.frame = CGRect(x: 0, y: bounds.maxY, width: bounds.width, height: 1) 168 - } 169 - 170 - override func viewDidMoveToWindow() { 171 - super.viewDidMoveToWindow() 172 - updateVisibility() 173 - NotificationCenter.default.addObserver( 174 - self, selector: #selector(updateVisibility), name: NSWindow.didEnterFullScreenNotification, object: window) 175 - NotificationCenter.default.addObserver( 176 - self, selector: #selector(updateVisibility), name: NSWindow.didExitFullScreenNotification, object: window) 177 - } 178 - 179 - deinit { 180 - NotificationCenter.default.removeObserver(self) 181 - } 182 - 183 - @objc private func updateVisibility() { 184 - separator.isHidden = window?.styleMask.contains(.fullScreen) == true 185 133 } 186 134 } 187 135
+35 -24
supacode/Features/Settings/Views/NotificationsSettingsView.swift
··· 5 5 @Bindable var store: StoreOf<SettingsFeature> 6 6 7 7 var body: some View { 8 - VStack(alignment: .leading) { 9 - Form { 10 - Section("Notifications") { 11 - Toggle( 12 - "Show bell icon next to worktree", 13 - isOn: $store.inAppNotificationsEnabled 8 + Form { 9 + Section { 10 + Toggle( 11 + isOn: $store.systemNotificationsEnabled 12 + ) { 13 + Text("System notifications") 14 + } 15 + .help("Show macOS system notifications") 16 + Toggle( 17 + isOn: $store.notificationSoundEnabled 18 + ) { 19 + Text("Play notification sound") 20 + Text( 21 + "Ignored when system notifications are enabled, as they play sounds" 22 + + " according to your settings." 14 23 ) 15 - .help("Show bell icon next to worktree") 16 - Toggle( 17 - "Play notification sound", 18 - isOn: $store.notificationSoundEnabled 19 - ) 20 - .help("Play a sound when a notification is received") 21 - Toggle( 22 - "System notifications", 23 - isOn: $store.systemNotificationsEnabled 24 - ) 25 - .help("Show macOS system notifications") 26 - Toggle( 27 - "Move notified worktree to top", 28 - isOn: $store.moveNotifiedWorktreeToTop 29 - ) 30 - .help("Bring the worktree to the top when the terminal receives a notification") 24 + }.disabled(store.systemNotificationsEnabled) 25 + } 26 + Section("Worktrees") { 27 + Toggle( 28 + isOn: $store.inAppNotificationsEnabled 29 + ) { 30 + Text("Notification badge") 31 + Text("Display an orange dot next to worktrees with unread notifications.") 32 + } 33 + Toggle( 34 + isOn: $store.moveNotifiedWorktreeToTop 35 + ) { 36 + Text("Prioritize unread worktrees") 37 + Text("Worktrees with unread notifications will be shown first in the list.") 31 38 } 32 39 } 33 - .formStyle(.grouped) 34 40 } 35 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 41 + .formStyle(.grouped) 42 + .padding(.top, -20) 43 + .padding(.leading, -8) 44 + .padding(.trailing, -6) 45 + 46 + .navigationTitle("Notifications") 36 47 } 37 48 }
+89 -229
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 3 3 4 4 struct RepositorySettingsView: View { 5 5 @Bindable var store: StoreOf<RepositorySettingsFeature> 6 - @State private var isBranchPickerPresented = false 7 - @State private var branchSearchText = "" 8 6 9 7 var body: some View { 10 8 let baseRefOptions = ··· 18 16 Form { 19 17 Section { 20 18 if store.isBranchDataLoaded { 21 - Button { 22 - branchSearchText = "" 23 - isBranchPickerPresented = true 24 - } label: { 25 - HStack { 26 - Text(store.settings.worktreeBaseRef ?? "Automatic (\(store.defaultWorktreeBaseRef))") 27 - .foregroundStyle(.primary) 28 - Spacer() 29 - Image(systemName: "chevron.up.chevron.down") 30 - .foregroundStyle(.secondary) 31 - .font(.caption) 32 - .accessibilityHidden(true) 19 + Picker(selection: $store.settings.worktreeBaseRef) { 20 + Text("Auto \(Text(store.defaultWorktreeBaseRef).foregroundStyle(.secondary))") 21 + .tag(String?.none) 22 + ForEach(baseRefOptions, id: \.self) { ref in 23 + Text(ref).tag(Optional(ref)) 33 24 } 34 - .contentShape(Rectangle()) 35 - } 36 - .buttonStyle(.plain) 37 - .popover(isPresented: $isBranchPickerPresented) { 38 - BranchPickerPopover( 39 - searchText: $branchSearchText, 40 - options: baseRefOptions, 41 - automaticLabel: "Automatic (\(store.defaultWorktreeBaseRef))", 42 - selection: store.settings.worktreeBaseRef, 43 - onSelect: { ref in 44 - store.settings.worktreeBaseRef = ref 45 - isBranchPickerPresented = false 46 - } 47 - ) 25 + } label: { 26 + Text("Base branch") 27 + Text("New worktrees branch from this ref.") 48 28 } 49 29 } else { 50 - ProgressView() 51 - .controlSize(.small) 52 - } 53 - } header: { 54 - VStack(alignment: .leading, spacing: 4) { 55 - Text("Branch new workspaces from") 56 - Text("Each workspace is an isolated copy of your codebase.") 57 - .foregroundStyle(.secondary) 30 + LabeledContent { 31 + ProgressView() 32 + .controlSize(.small) 33 + } label: { 34 + Text("Base branch") 35 + Text("New worktrees branch from this ref.") 36 + } 58 37 } 59 38 } 60 39 Section { 61 - VStack(alignment: .leading) { 62 - TextField( 63 - "Inherit global default", 64 - text: worktreeBaseDirectoryPath 65 - ) 66 - .textFieldStyle(.roundedBorder) 67 - Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") 68 - .foregroundStyle(.secondary) 69 - Text("Example new worktree path: \(exampleWorktreePath)") 70 - .foregroundStyle(.secondary) 71 - .monospaced() 40 + Toggle(isOn: settings.copyIgnoredOnWorktreeCreate) { 41 + Text("Copy ignored files to new worktrees") 42 + Text("Copies gitignored files from the main worktree.") 72 43 } 73 - .frame(maxWidth: .infinity, alignment: .leading) 74 - Toggle( 75 - "Copy ignored files to new worktrees", 76 - isOn: settings.copyIgnoredOnWorktreeCreate 77 - ) 78 44 .disabled(store.isBareRepository) 79 - Toggle( 80 - "Copy untracked files to new worktrees", 81 - isOn: settings.copyUntrackedOnWorktreeCreate 82 - ) 45 + Toggle(isOn: settings.copyUntrackedOnWorktreeCreate) { 46 + Text("Copy untracked files to new worktrees") 47 + Text("Copies untracked files from the main worktree.") 48 + } 83 49 .disabled(store.isBareRepository) 84 50 if store.isBareRepository { 85 51 Text("Copy flags are ignored for bare repositories.") 86 - .foregroundStyle(.secondary) 52 + .font(.footnote) 53 + .foregroundStyle(.tertiary) 87 54 } 55 + TextField( 56 + text: worktreeBaseDirectoryPath, 57 + prompt: Text(SupacodePaths.reposDirectory.path(percentEncoded: false)) 58 + ) { 59 + Text("Default directory").monospaced(false) 60 + Text("Parent path for new worktrees.").monospaced(false) 61 + }.monospaced() 88 62 } header: { 89 - VStack(alignment: .leading, spacing: 4) { 90 - Text("Worktree") 91 - Text("Applies when creating a new worktree") 92 - .foregroundStyle(.secondary) 93 - } 63 + Text("Worktree") 64 + } footer: { 65 + Text("e.g., `\(exampleWorktreePath)`") 94 66 } 95 - Section { 96 - Picker( 97 - "Merge strategy", 98 - selection: settings.pullRequestMergeStrategy 99 - ) { 67 + Section("Pull Requests") { 68 + Picker(selection: settings.pullRequestMergeStrategy) { 100 69 ForEach(PullRequestMergeStrategy.allCases) { strategy in 101 70 Text(strategy.title) 102 71 .tag(strategy) 103 72 } 104 - } 105 - .labelsHidden() 106 - } header: { 107 - VStack(alignment: .leading, spacing: 4) { 108 - Text("Pull Requests") 109 - Text("Used when merging PRs from the command palette") 110 - .foregroundStyle(.secondary) 73 + } label: { 74 + Text("Merge strategy") 75 + Text("Used when merging PRs from the command palette.") 111 76 } 112 77 } 113 - Section { 78 + Section("Environment Variables") { 114 79 ScriptEnvironmentRow( 115 80 name: "SUPACODE_WORKTREE_PATH", 116 81 description: "Path to the active worktree." ··· 120 85 value: store.rootURL.path(percentEncoded: false), 121 86 description: "Path to the repository root." 122 87 ) 123 - } header: { 124 - VStack(alignment: .leading, spacing: 4) { 125 - Text("Environment Variables") 126 - Text("Exported in all scripts below") 127 - .foregroundStyle(.secondary) 128 - } 129 88 } 130 - Section { 131 - ZStack(alignment: .topLeading) { 132 - PlainTextEditor( 133 - text: settings.setupScript 134 - ) 135 - .frame(minHeight: 120) 136 - if store.settings.setupScript.isEmpty { 137 - Text("claude --dangerously-skip-permissions") 138 - .foregroundStyle(.secondary) 139 - .padding(.leading, 6) 140 - .font(.body) 141 - .allowsHitTesting(false) 142 - } 143 - } 144 - } header: { 145 - VStack(alignment: .leading, spacing: 4) { 146 - Text("Setup Script") 147 - Text("Initial setup script that will be launched once after worktree creation") 148 - .foregroundStyle(.secondary) 149 - } 150 - } 151 - Section { 152 - ZStack(alignment: .topLeading) { 153 - PlainTextEditor( 154 - text: settings.runScript 155 - ) 156 - .frame(minHeight: 120) 157 - if store.settings.runScript.isEmpty { 158 - Text("npm run dev") 159 - .foregroundStyle(.secondary) 160 - .padding(.leading, 6) 161 - .font(.body) 162 - .allowsHitTesting(false) 163 - } 164 - } 165 - } header: { 166 - VStack(alignment: .leading, spacing: 4) { 167 - Text("Run Script") 168 - Text("Run script launched on demand from the toolbar") 169 - .foregroundStyle(.secondary) 170 - } 171 - } 172 - Section { 173 - ZStack(alignment: .topLeading) { 174 - PlainTextEditor( 175 - text: settings.archiveScript 176 - ) 177 - .frame(minHeight: 120) 178 - if store.settings.archiveScript.isEmpty { 179 - Text("docker compose down") 180 - .foregroundStyle(.secondary) 181 - .padding(.leading, 6) 182 - .font(.body) 183 - .allowsHitTesting(false) 184 - } 185 - } 186 - } header: { 187 - VStack(alignment: .leading, spacing: 4) { 188 - Text("Archive Script") 189 - Text("Archive script that runs before a worktree is archived") 190 - .foregroundStyle(.secondary) 191 - } 192 - } 193 - Section { 194 - ZStack(alignment: .topLeading) { 195 - PlainTextEditor( 196 - text: settings.deleteScript 197 - ) 198 - .frame(minHeight: 120) 199 - if store.settings.deleteScript.isEmpty { 200 - Text("docker compose down") 201 - .foregroundStyle(.secondary) 202 - .padding(.leading, 6) 203 - .font(.body) 204 - .allowsHitTesting(false) 205 - } 206 - } 207 - } header: { 208 - VStack(alignment: .leading, spacing: 4) { 209 - Text("Delete Script") 210 - Text("Delete script that runs before a worktree is deleted") 211 - .foregroundStyle(.secondary) 212 - } 213 - } 89 + ScriptSection( 90 + title: "Setup Script", 91 + subtitle: "Runs once after worktree creation.", 92 + text: settings.setupScript, 93 + placeholder: "claude --dangerously-skip-permissions" 94 + ) 95 + ScriptSection( 96 + title: "Run Script", 97 + subtitle: "Launched on demand from the toolbar.", 98 + text: settings.runScript, 99 + placeholder: "npm run dev" 100 + ) 101 + ScriptSection( 102 + title: "Archive Script", 103 + subtitle: "Runs before a worktree is archived.", 104 + text: settings.archiveScript, 105 + placeholder: "docker compose down" 106 + ) 107 + ScriptSection( 108 + title: "Delete Script", 109 + subtitle: "Runs before a worktree is deleted.", 110 + text: settings.deleteScript, 111 + placeholder: "docker compose down" 112 + ) 214 113 } 215 114 .formStyle(.grouped) 216 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 115 + .padding(.top, -20) 116 + .padding(.leading, -8) 117 + .padding(.trailing, -6) 217 118 .task { 218 119 store.send(.task) 219 120 } 220 121 } 221 122 } 222 123 223 - private struct BranchPickerPopover: View { 224 - @Binding var searchText: String 225 - let options: [String] 226 - let automaticLabel: String 227 - let selection: String? 228 - let onSelect: (String?) -> Void 229 - @FocusState private var isSearchFocused: Bool 124 + // MARK: - Script section. 230 125 231 - var filteredOptions: [String] { 232 - if searchText.isEmpty { return options } 233 - return options.filter { $0.localizedCaseInsensitiveContains(searchText) } 234 - } 126 + private struct ScriptSection: View { 127 + let title: String 128 + let subtitle: String 129 + let text: Binding<String> 130 + let placeholder: String 235 131 236 132 var body: some View { 237 - VStack(spacing: 0) { 238 - TextField("Filter branches...", text: $searchText) 239 - .textFieldStyle(.roundedBorder) 240 - .focused($isSearchFocused) 241 - .padding(8) 242 - Divider() 243 - List { 244 - Button { 245 - onSelect(nil) 246 - } label: { 247 - HStack { 248 - Text(automaticLabel) 249 - Spacer() 250 - if selection == nil { 251 - Image(systemName: "checkmark") 252 - .foregroundStyle(.tint) 253 - .accessibilityHidden(true) 254 - } 255 - } 256 - .padding(.vertical, 6) 257 - .contentShape(Rectangle()) 258 - } 259 - .buttonStyle(.plain) 260 - ForEach(filteredOptions, id: \.self) { ref in 261 - Button { 262 - onSelect(ref) 263 - } label: { 264 - HStack { 265 - Text(ref) 266 - Spacer() 267 - if selection == ref { 268 - Image(systemName: "checkmark") 269 - .foregroundStyle(.tint) 270 - .accessibilityHidden(true) 271 - } 272 - } 273 - .padding(.vertical, 6) 274 - .contentShape(Rectangle()) 275 - } 276 - .buttonStyle(.plain) 277 - } 278 - } 279 - .listStyle(.plain) 133 + Section { 134 + TextField(title, text: text, axis: .vertical) 135 + .labelsHidden() 136 + .lineLimit(5, reservesSpace: true) 137 + .monospaced() 138 + } header: { 139 + Text(title) 140 + Text(subtitle) 141 + } footer: { 142 + Text("e.g., `\(placeholder)`") 280 143 } 281 - .frame(width: 300, height: 350) 282 - .onAppear { isSearchFocused = true } 283 144 } 284 145 } 146 + 147 + // MARK: - Environment row. 285 148 286 149 private struct ScriptEnvironmentRow: View { 287 150 let name: String ··· 289 152 let description: String 290 153 291 154 var body: some View { 292 - VStack(alignment: .leading, spacing: 2) { 293 - Text(name) 294 - .monospaced() 155 + LabeledContent { 295 156 if let value { 296 - Text(value) 297 - .foregroundStyle(.secondary) 298 - .monospaced() 157 + Text(value).monospaced() 299 158 } 159 + } label: { 160 + Text(name).monospaced() 300 161 Text(description) 301 - .foregroundStyle(.tertiary) 302 162 } 303 163 } 304 164 }
-17
supacode/Features/Settings/Views/SettingsDetailView.swift
··· 1 - import SwiftUI 2 - 3 - struct SettingsDetailView<Content: View>: View { 4 - let content: Content 5 - 6 - init(@ViewBuilder content: () -> Content) { 7 - self.content = content() 8 - } 9 - 10 - var body: some View { 11 - content 12 - .scenePadding(.top) 13 - .scenePadding(.horizontal) 14 - .scenePadding(.bottom) 15 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 16 - } 17 - }
+43
supacode/Features/Settings/Views/SettingsOpenBridge.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + // MARK: - Selection → settings window bridge. 5 + 6 + /// Observes `store.settings.selection` and opens the dedicated settings window when it becomes non-nil. 7 + /// Applied to the main window content so the environment action is always available. 8 + private struct OpenSettingsOnSelection: ViewModifier { 9 + @Environment(\.openWindow) private var openWindow 10 + let store: StoreOf<AppFeature> 11 + 12 + func body(content: Content) -> some View { 13 + content 14 + .onChange(of: store.settings.selection) { _, new in 15 + guard new != nil else { return } 16 + openWindow(id: WindowID.settings) 17 + } 18 + } 19 + } 20 + 21 + extension View { 22 + func openSettingsOnSelection(store: StoreOf<AppFeature>) -> some View { 23 + modifier(OpenSettingsOnSelection(store: store)) 24 + } 25 + } 26 + 27 + // MARK: - Menu button. 28 + 29 + /// Settings menu button that opens the dedicated settings window and supports custom keyboard shortcuts. 30 + struct SettingsMenuButton: View { 31 + @Environment(\.openWindow) private var openWindow 32 + let shortcutOverrides: [AppShortcutID: AppShortcutOverride] 33 + let onOpen: () -> Void 34 + 35 + var body: some View { 36 + let settings = AppShortcuts.openSettings.effective(from: shortcutOverrides) 37 + Button("Settings...", systemImage: "gear") { 38 + onOpen() 39 + openWindow(id: WindowID.settings) 40 + } 41 + .appKeyboardShortcut(settings) 42 + } 43 + }
-1
supacode/Features/Settings/Views/SettingsSection.swift
··· 6 6 case worktree 7 7 case shortcuts 8 8 case updates 9 - case advanced 10 9 case github 11 10 case repository(Repository.ID) 12 11 }
+76 -84
supacode/Features/Settings/Views/SettingsView.swift
··· 1 1 import ComposableArchitecture 2 + import Kingfisher 2 3 import SwiftUI 3 4 4 - extension View { 5 - @ViewBuilder 6 - fileprivate func removingSidebarToggle() -> some View { 7 - if #available(macOS 14.0, *) { 8 - toolbar(removing: .sidebarToggle) 9 - } else { 10 - self 5 + /// Sidebar label that shows a GitHub owner avatar next to the repository name. 6 + private struct RepositoryLabel: View { 7 + let name: String 8 + let rootURL: URL 9 + 10 + @State private var avatarURL: URL? 11 + 12 + var body: some View { 13 + Label { 14 + Text(name) 15 + } icon: { 16 + KFImage(avatarURL) 17 + .placeholder { 18 + Image(systemName: "folder") 19 + .padding(-3) 20 + .accessibilityHidden(true) 21 + } 22 + .resizable() 23 + .aspectRatio(1, contentMode: .fit) 24 + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) 25 + .padding(3) 11 26 } 27 + .task(id: rootURL) { 28 + avatarURL = await Self.ownerAvatarURL(for: rootURL) 29 + } 30 + } 31 + 32 + private static func ownerAvatarURL(for rootURL: URL) async -> URL? { 33 + guard let info = await GitClient().remoteInfo(for: rootURL) else { 34 + return nil 35 + } 36 + return URL(string: "https://github.com/\(info.owner).png?size=64") 12 37 } 13 38 } 14 39 ··· 27 52 let selection = settingsStore.selection ?? .general 28 53 29 54 NavigationSplitView(columnVisibility: .constant(.all)) { 30 - VStack(spacing: 0) { 31 - List(selection: $settingsStore.selection.sending(\.setSelection)) { 32 - Label("General", systemImage: "gearshape") 33 - .tag(SettingsSection.general) 34 - Label("Notifications", systemImage: "bell") 35 - .tag(SettingsSection.notifications) 36 - Label("Worktree", systemImage: "archivebox") 37 - .tag(SettingsSection.worktree) 38 - Label("Shortcuts", systemImage: "command") 39 - .tag(SettingsSection.shortcuts) 40 - Label("Updates", systemImage: "arrow.down.circle") 41 - .tag(SettingsSection.updates) 42 - Label("Advanced", systemImage: "gearshape.2") 43 - .tag(SettingsSection.advanced) 44 - Label("GitHub", systemImage: "arrow.triangle.branch") 45 - .tag(SettingsSection.github) 55 + List(selection: $settingsStore.selection.sending(\.setSelection)) { 56 + Label("General", systemImage: "gearshape") 57 + .tag(SettingsSection.general) 58 + Label("Notifications", systemImage: "bell") 59 + .tag(SettingsSection.notifications) 60 + Label("Worktrees", systemImage: "list.dash") 61 + .tag(SettingsSection.worktree) 62 + Label("GitHub", image: "github-mark") 63 + .tag(SettingsSection.github) 64 + Label("Shortcuts", systemImage: "keyboard") 65 + .tag(SettingsSection.shortcuts) 66 + Label("Updates", systemImage: "arrow.down.circle") 67 + .tag(SettingsSection.updates) 46 68 47 - Section("Repositories") { 48 - ForEach(repositories) { repository in 49 - Text(repository.name) 69 + Section("Repositories") { 70 + ForEach(settingsStore.sortedRepositoryIDs, id: \.self) { repositoryID in 71 + if let repository = repositories[id: repositoryID] { 72 + RepositoryLabel(name: repository.name, rootURL: repository.rootURL) 50 73 .tag(SettingsSection.repository(repository.id)) 51 74 } 52 75 } 53 76 } 54 - .listStyle(.sidebar) 55 - .frame(minWidth: 220, maxHeight: .infinity) 56 - .navigationSplitViewColumnWidth(220) 57 - .removingSidebarToggle() 58 77 } 78 + .listStyle(.sidebar) 79 + .frame(minWidth: 220, maxHeight: .infinity) 80 + .navigationSplitViewColumnWidth(220) 81 + .toolbar(removing: .sidebarToggle) 59 82 } detail: { 60 83 switch selection { 61 84 case .general: 62 - SettingsDetailView { 63 - AppearanceSettingsView(store: settingsStore) 64 - .navigationTitle("General") 65 - .navigationSubtitle("Appearance and preferences") 66 - } 85 + AppearanceSettingsView(store: settingsStore) 67 86 case .notifications: 68 - SettingsDetailView { 69 - NotificationsSettingsView(store: settingsStore) 70 - .navigationTitle("Notifications") 71 - .navigationSubtitle("In-app alerts and delivery") 72 - } 87 + NotificationsSettingsView(store: settingsStore) 73 88 case .worktree: 74 - SettingsDetailView { 75 - WorktreeSettingsView(store: settingsStore) 76 - .navigationTitle("Worktree") 77 - .navigationSubtitle("Archive behavior") 78 - } 89 + WorktreeSettingsView(store: settingsStore) 79 90 case .shortcuts: 80 91 KeyboardShortcutsSettingsView(store: settingsStore) 81 - .navigationTitle("Keyboard Shortcuts") 82 - .navigationSubtitle("Customize key bindings") 83 92 case .updates: 84 - SettingsDetailView { 85 - UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) 86 - .navigationTitle("Updates") 87 - .navigationSubtitle("Update preferences") 88 - } 89 - case .advanced: 90 - SettingsDetailView { 91 - AdvancedSettingsView(store: settingsStore) 92 - .navigationTitle("Advanced") 93 - .navigationSubtitle("Analytics and diagnostics") 94 - } 93 + UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) 95 94 case .github: 96 - SettingsDetailView { 97 - GithubSettingsView(store: settingsStore) 98 - .navigationTitle("GitHub") 99 - .navigationSubtitle("GitHub CLI integration") 100 - } 95 + GithubSettingsView(store: settingsStore) 101 96 case .repository(let repositoryID): 102 97 if let repository = repositories[id: repositoryID] { 103 - SettingsDetailView { 104 - IfLetStore( 105 - settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) 106 - ) { repositorySettingsStore in 107 - RepositorySettingsView(store: repositorySettingsStore) 108 - .id(repository.id) 109 - .navigationTitle(repository.name) 110 - .navigationSubtitle(repository.rootURL.path(percentEncoded: false)) 111 - } 98 + IfLetStore( 99 + settingsStore.scope(state: \.repositorySettings, action: \.repositorySettings) 100 + ) { repositorySettingsStore in 101 + RepositorySettingsView(store: repositorySettingsStore) 102 + .id(repository.id) 103 + .navigationTitle(repository.name) 112 104 } 113 105 } else { 114 - SettingsDetailView { 115 - Text("Repository not found.") 116 - .foregroundStyle(.secondary) 117 - .frame(maxWidth: .infinity, alignment: .leading) 118 - .navigationTitle("Repositories") 119 - } 106 + Text("Repository not found.") 107 + .foregroundStyle(.secondary) 108 + .frame(maxWidth: .infinity, alignment: .leading) 109 + .navigationTitle("Repositories") 120 110 } 121 111 } 122 112 } 123 113 .toolbar { 124 - // Prevent the toolbar from collapsing when switching between 125 - // detail views with and without toolbar items (e.g. Shortcuts). 114 + // Invisible item keeps the toolbar stable when switching between 115 + // detail views with and without toolbar items. 126 116 ToolbarItem(placement: .principal) { 127 117 Color.clear.frame(width: 0, height: 0) 128 118 } ··· 131 121 .alert(store: settingsStore.scope(state: \.$alert, action: \.alert)) 132 122 .alert(store: store.scope(state: \.$alert, action: \.alert)) 133 123 .frame(minWidth: 750, minHeight: 500) 134 - .background { 135 - WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme) 136 - WindowLevelSetter(level: .normal) 124 + .onAppear { 125 + guard settingsStore.selection == nil else { return } 126 + settingsStore.send(.setSelection(.general)) 137 127 } 138 - .ignoresSafeArea(.container, edges: .top) 128 + .onDisappear { 129 + settingsStore.send(.setSelection(nil)) 130 + } 139 131 } 140 132 }
+31 -30
supacode/Features/Settings/Views/UpdatesSettingsView.swift
··· 6 6 let updatesStore: StoreOf<UpdatesFeature> 7 7 8 8 var body: some View { 9 - VStack(alignment: .leading, spacing: 0) { 10 - Form { 11 - Section("Update Channel") { 12 - Picker("Channel", selection: $settingsStore.updateChannel) { 13 - Text("Stable").tag(UpdateChannel.stable) 14 - Text("Tip").tag(UpdateChannel.tip) 15 - } 9 + Form { 10 + Section { 11 + Picker(selection: $settingsStore.updateChannel) { 12 + Text("Stable").tag(UpdateChannel.stable) 13 + Text("Tip").tag(UpdateChannel.tip) 14 + } label: { 15 + Text("Channel") 16 + Text( 17 + settingsStore.updateChannel == .stable ? "Recommended for most users." : "Get the latest features early.") 16 18 } 17 - Section("Automatic Updates") { 18 - Toggle( 19 - "Check for updates automatically", 20 - isOn: $settingsStore.updatesAutomaticallyCheckForUpdates 21 - ) 22 - Toggle( 23 - "Download and install updates automatically", 24 - isOn: $settingsStore.updatesAutomaticallyDownloadUpdates 25 - ) 26 - .disabled(!settingsStore.updatesAutomaticallyCheckForUpdates) 19 + Button { 20 + updatesStore.send(.checkForUpdates) 21 + } label: { 22 + Text("Check for Updates now") 23 + .frame(maxWidth: .infinity) 27 24 } 25 + .buttonStyle(.bordered) 26 + .controlSize(.large) 27 + .buttonBorderShape(.roundedRectangle) 28 28 } 29 - .formStyle(.grouped) 30 - 31 - HStack { 32 - Button("Check for Updates Now") { 33 - updatesStore.send(.checkForUpdates) 29 + Section("Automatic Updates") { 30 + Toggle(isOn: $settingsStore.updatesAutomaticallyCheckForUpdates) { 31 + Text("Check for updates automatically") 32 + Text("Periodically checks for new versions while Supacode is running.") 34 33 } 35 - .help( 36 - { 37 - let display = AppShortcuts.checkForUpdates.effective(from: settingsStore.shortcutOverrides)?.display 38 - return "Check for Updates (\(display ?? "none"))" 39 - }()) 40 - Spacer() 34 + Toggle(isOn: $settingsStore.updatesAutomaticallyDownloadUpdates) { 35 + Text("Download and install updates automatically") 36 + Text("Downloads updates in the background. You will be prompted to restart to apply them.") 37 + } 38 + .disabled(!settingsStore.updatesAutomaticallyCheckForUpdates) 41 39 } 42 - .padding(.top) 43 40 } 44 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 41 + .formStyle(.grouped) 42 + .padding(.top, -20) 43 + .padding(.leading, -8) 44 + .padding(.trailing, -6) 45 + .navigationTitle("Updates") 45 46 } 46 47 }
+36 -51
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 5 5 @Bindable var store: StoreOf<SettingsFeature> 6 6 7 7 var body: some View { 8 - let exampleRepositoryRoot = FileManager.default.homeDirectoryForCurrentUser 9 - .appending(path: "code/my-repo", directoryHint: .isDirectory) 10 - let exampleWorktreePath = SupacodePaths.exampleWorktreePath( 11 - for: exampleRepositoryRoot, 12 - globalDefaultPath: store.defaultWorktreeBaseDirectoryPath, 13 - repositoryOverridePath: nil 14 - ) 15 - VStack(alignment: .leading) { 16 - Form { 17 - Section("Worktree") { 18 - VStack(alignment: .leading) { 19 - TextField( 20 - "Default: current behavior", 21 - text: $store.defaultWorktreeBaseDirectoryPath 22 - ) 23 - .textFieldStyle(.roundedBorder) 24 - Text("Default directory for new worktrees across repositories. Leave empty to keep current behavior.") 25 - .foregroundStyle(.secondary) 26 - Text("Example new worktree path: \(exampleWorktreePath)") 27 - .foregroundStyle(.secondary) 28 - .monospaced() 29 - } 30 - .frame(maxWidth: .infinity, alignment: .leading) 31 - VStack(alignment: .leading) { 32 - Toggle( 33 - "Also delete local branch when deleting a worktree", 34 - isOn: $store.deleteBranchOnDeleteWorktree 35 - ) 36 - .help("Delete the local branch when deleting a worktree") 37 - Text("Removes the local branch along with the worktree. Remote branches must be deleted on GitHub.") 38 - .foregroundStyle(.secondary) 39 - Text("Uncommitted changes will be lost.") 40 - .foregroundStyle(.red) 41 - } 42 - .frame(maxWidth: .infinity, alignment: .leading) 43 - Toggle( 44 - "Automatically archive merged worktrees", 45 - isOn: $store.automaticallyArchiveMergedWorktrees 46 - ) 47 - .help("Archive worktrees automatically when their pull requests are merged.") 48 - VStack(alignment: .leading) { 49 - Toggle( 50 - "Prompt for branch name during creation", 51 - isOn: $store.promptForWorktreeCreation 52 - ) 53 - .help("Ask for branch name and base ref before creating a worktree.") 54 - Text("When enabled, you choose the branch name and where it branches from before creating the worktree.") 55 - .foregroundStyle(.secondary) 56 - } 8 + let defaultPath = SupacodePaths.reposDirectory.path(percentEncoded: false) 9 + let resolvedBase = 10 + SupacodePaths.normalizedWorktreeBaseDirectoryPath( 11 + store.defaultWorktreeBaseDirectoryPath 12 + ) ?? defaultPath 13 + let examplePath = "\(resolvedBase)*/**/*" 14 + Form { 15 + Section { 16 + Toggle(isOn: $store.promptForWorktreeCreation) { 17 + Text("Prompt for branch name on creation") 18 + Text("Choose the branch name and base ref before creating the worktree.") 19 + } 20 + TextField( 21 + text: $store.defaultWorktreeBaseDirectoryPath, 22 + prompt: Text(defaultPath) 23 + ) { 24 + Text("Default directory").monospaced(false) 25 + Text("Parent path for new worktrees.").monospaced(false) 26 + }.monospaced() 27 + } footer: { 28 + Text("e.g., `\(examplePath)`") 29 + } 30 + Section("Clean-up") { 31 + Toggle(isOn: $store.automaticallyArchiveMergedWorktrees) { 32 + Text("Automatically archive merged worktrees") 33 + Text("Archives worktrees when their pull requests are merged.") 34 + } 35 + Toggle(isOn: $store.deleteBranchOnDeleteWorktree) { 36 + Text("Delete local branch with worktree") 37 + Text("Removes the local branch along with the worktree. Remote branches must be deleted on GitHub.") 38 + Text("Uncommitted changes will be lost.").foregroundStyle(.red) 57 39 } 58 40 } 59 - .formStyle(.grouped) 60 41 } 61 - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 42 + .formStyle(.grouped) 43 + .padding(.top, -20) 44 + .padding(.leading, -8) 45 + .padding(.trailing, -6) 46 + .navigationTitle("Worktrees") 62 47 } 63 48 }
+62
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 1 1 import AppKit 2 2 import GhosttyKit 3 + import Sharing 3 4 import SwiftUI 4 5 import UniformTypeIdentifiers 5 6 6 7 final class GhosttyRuntime { 8 + private static let logger = SupaLogger("Ghostty") 9 + 7 10 final class SurfaceReference { 8 11 let surface: ghostty_surface_t 9 12 var isValid = true ··· 141 144 func unregisterSurface(_ ref: SurfaceReference) { 142 145 ref.invalidate() 143 146 surfaceRefs = surfaceRefs.filter { $0.isValid } 147 + } 148 + 149 + /// Reloads the full app config from disk and re-applies the current color scheme. 150 + func reloadAppConfig() { 151 + guard let app else { 152 + Self.logger.warning("Cannot reload app config: Ghostty app instance is nil.") 153 + return 154 + } 155 + var target = ghostty_target_s() 156 + target.tag = GHOSTTY_TARGET_APP 157 + guard let config = Self.loadConfig() else { 158 + Self.logger.warning("Failed to reload app config.") 159 + return 160 + } 161 + applyConfig(config, target: target, app: app) 162 + ghostty_config_free(config) 163 + if let lastColorScheme { 164 + ghostty_app_set_color_scheme(app, lastColorScheme) 165 + applyColorSchemeToSurfaces(lastColorScheme) 166 + } 144 167 } 145 168 146 169 func reloadConfig(soft: Bool, target: ghostty_target_s) { ··· 440 463 } 441 464 442 465 private static func loadConfig() -> ghostty_config_t? { 466 + @Shared(.settingsFile) var settingsFile 443 467 guard let config = ghostty_config_new() else { return nil } 444 468 ghostty_config_load_default_files(config) 445 469 ghostty_config_load_recursive_files(config) 446 470 ghostty_config_load_cli_args(config) 471 + loadBundledOverrides(into: config) 472 + loadBundledTheme(into: config, enabled: settingsFile.global.terminalThemeSyncEnabled) 447 473 ghostty_config_finalize(config) 448 474 return config 475 + } 476 + 477 + /// Applies Supacode-specific config (padding values) that takes precedence over user settings. 478 + private static func loadBundledOverrides(into config: ghostty_config_t) { 479 + let defaults = "window-padding-x = 14\nwindow-padding-y = 12,0\n" 480 + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("supacode-defaults.conf") 481 + do { 482 + try defaults.write(to: tempURL, atomically: true, encoding: .utf8) 483 + } catch { 484 + logger.warning("Failed to write bundled defaults: \(error.localizedDescription)") 485 + return 486 + } 487 + tempURL.path.withCString { ghostty_config_load_file(config, $0) } 488 + } 489 + 490 + /// When terminal theme sync is enabled, loads the bundled Supacode 491 + /// light/dark theme, overriding any user-configured theme. When disabled, the user's Ghostty theme is preserved. 492 + private static func loadBundledTheme(into config: ghostty_config_t, enabled: Bool) { 493 + guard enabled else { return } 494 + guard 495 + let lightPath = Bundle.main.path(forResource: "Supacode Light", ofType: nil), 496 + let darkPath = Bundle.main.path(forResource: "Supacode Dark", ofType: nil) 497 + else { 498 + assertionFailure("Bundled Supacode themes missing from app bundle.") 499 + logger.warning("Bundled Supacode themes missing from app bundle.") 500 + return 501 + } 502 + let line = "theme = light:\(lightPath),dark:\(darkPath)\n" 503 + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("supacode-theme.conf") 504 + do { 505 + try line.write(to: tempURL, atomically: true, encoding: .utf8) 506 + } catch { 507 + logger.warning("Failed to write bundled theme config: \(error.localizedDescription)") 508 + return 509 + } 510 + tempURL.path.withCString { ghostty_config_load_file(config, $0) } 449 511 } 450 512 451 513 func keyboardShortcut(for action: String) -> KeyboardShortcut? {
+22
supacode/Resources/Themes/Supacode Dark
··· 1 + palette = 0=#000000 2 + palette = 1=#ff4245 3 + palette = 2=#30d158 4 + palette = 3=#ffd600 5 + palette = 4=#0091ff 6 + palette = 5=#db34f2 7 + palette = 6=#3cd3fe 8 + palette = 7=#ffffff 9 + palette = 8=#98989d 10 + palette = 9=#ff676a 11 + palette = 10=#59da79 12 + palette = 11=#ffde33 13 + palette = 12=#33a7ff 14 + palette = 13=#ff375f 15 + palette = 14=#00d2e0 16 + palette = 15=#ffffff 17 + background = #1e1e1e 18 + foreground = #ffffff 19 + cursor-color = #9d9d9d 20 + cursor-text = #1e1e1e 21 + selection-background = #3f638b 22 + selection-foreground = #ffffff
+22
supacode/Resources/Themes/Supacode Light
··· 1 + palette = 0=#000000 2 + palette = 1=#ff383c 3 + palette = 2=#34c759 4 + palette = 3=#ffcc00 5 + palette = 4=#0088ff 6 + palette = 5=#cb30e0 7 + palette = 6=#00c0e8 8 + palette = 7=#ffffff 9 + palette = 8=#8e8e93 10 + palette = 9=#ff5f63 11 + palette = 10=#5cd27a 12 + palette = 11=#ffd633 13 + palette = 12=#339fff 14 + palette = 13=#ff2d55 15 + palette = 14=#00c3d0 16 + palette = 15=#ffffff 17 + background = #ffffff 18 + foreground = #000000 19 + cursor-color = #9d9d9d 20 + cursor-text = #ffffff 21 + selection-background = #b3d7ff 22 + selection-foreground = #000000
+1 -8
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 8 8 9 9 @MainActor 10 10 struct AppFeatureCommandPaletteTests { 11 - @Test(.dependencies) func openSettingsShowsWindow() async { 12 - let shown = LockIsolated(false) 11 + @Test(.dependencies) func openSettingsSetsSelection() async { 13 12 var state = AppFeature.State() 14 13 state.settings.selection = .updates 15 14 let store = TestStore(initialState: state) { 16 15 AppFeature() 17 - } withDependencies: { 18 - $0.settingsWindowClient.show = { 19 - shown.withValue { $0 = true } 20 - } 21 16 } 22 17 23 18 await store.send(.commandPalette(.delegate(.openSettings))) 24 19 await store.receive(\.settings.setSelection) { 25 20 $0.settings.selection = .general 26 21 } 27 - await store.finish() 28 - #expect(shown.value) 29 22 } 30 23 31 24 @Test(.dependencies) func newWorktreeDispatchesCreateRandomWorktree() async {
+46 -1
supacodeTests/SettingsFeatureTests.swift
··· 27 27 githubIntegrationEnabled: true, 28 28 deleteBranchOnDeleteWorktree: false, 29 29 automaticallyArchiveMergedWorktrees: true, 30 - promptForWorktreeCreation: true 30 + promptForWorktreeCreation: true, 31 + terminalThemeSyncEnabled: false, 31 32 ) 32 33 @Shared(.settingsFile) var settingsFile 33 34 $settingsFile.withLock { $0.global = loaded } ··· 54 55 $0.deleteBranchOnDeleteWorktree = false 55 56 $0.automaticallyArchiveMergedWorktrees = true 56 57 $0.promptForWorktreeCreation = true 58 + $0.terminalThemeSyncEnabled = false 57 59 } 58 60 await store.receive(\.delegate.settingsChanged) 59 61 } ··· 142 144 } 143 145 } 144 146 147 + @Test(.dependencies) func setSelectionNilClosesSettingsWindow() async { 148 + var state = SettingsFeature.State() 149 + state.selection = .general 150 + let store = TestStore(initialState: state) { 151 + SettingsFeature() 152 + } 153 + 154 + await store.send(.setSelection(nil)) { 155 + $0.selection = nil 156 + } 157 + } 158 + 145 159 @Test(.dependencies) func loadingSettingsDoesNotResetSelection() async { 146 160 let rootURL = URL(fileURLWithPath: "/tmp/repo") 147 161 let selection = SettingsSection.repository("repo-id") ··· 247 261 await store.receive(\.delegate.settingsChanged) 248 262 #expect(store.state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath == expectedPath) 249 263 #expect(settingsFile.global.defaultWorktreeBaseDirectoryPath == expectedPath) 264 + } 265 + 266 + // MARK: - Sorted repositories. 267 + 268 + @Test(.dependencies) func repositoriesChangedSortsByNameCaseInsensitive() async { 269 + let repoC = Repository( 270 + id: "/tmp/charlie", 271 + rootURL: URL(fileURLWithPath: "/tmp/charlie"), 272 + name: "Charlie", 273 + worktrees: [], 274 + ) 275 + let repoA = Repository( 276 + id: "/tmp/alpha", 277 + rootURL: URL(fileURLWithPath: "/tmp/alpha"), 278 + name: "alpha", 279 + worktrees: [], 280 + ) 281 + let repoB = Repository( 282 + id: "/tmp/bravo", 283 + rootURL: URL(fileURLWithPath: "/tmp/bravo"), 284 + name: "Bravo", 285 + worktrees: [], 286 + ) 287 + 288 + let store = TestStore(initialState: SettingsFeature.State()) { 289 + SettingsFeature() 290 + } 291 + 292 + await store.send(.repositoriesChanged([repoC, repoA, repoB])) { 293 + $0.sortedRepositoryIDs = [repoA.id, repoB.id, repoC.id] 294 + } 250 295 } 251 296 252 297 // MARK: - Keyboard shortcut overrides.