Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public struct SettingsFeature {
public var confirmQuitMode: ConfirmQuitMode
public var terminateSessionsOnQuit: Bool
public var remoteSessionPersistenceEnabled: Bool
public var appVisibility: AppVisibility
public var cliInstallState = CLIInstallState.checking
/// Installed editors in menu order, resolved once off the picker's body.
public var installedOpenActions: [OpenWorktreeAction]
Expand Down Expand Up @@ -130,6 +131,7 @@ public struct SettingsFeature {
confirmQuitMode = settings.confirmQuitMode
terminateSessionsOnQuit = settings.terminateSessionsOnQuit
remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled
appVisibility = settings.appVisibility
defaultWorktreeBaseDirectoryPath =
SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? ""
}
Expand Down Expand Up @@ -170,7 +172,8 @@ public struct SettingsFeature {
autoUpdateAgentIntegrationsEnabled: autoUpdateAgentIntegrationsEnabled,
confirmQuitMode: confirmQuitMode,
terminateSessionsOnQuit: terminateSessionsOnQuit,
remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled
remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled,
appVisibility: appVisibility
)
}
}
Expand All @@ -181,6 +184,7 @@ public struct SettingsFeature {
case repositoriesChanged([SettingsRepositorySummary])
case setSelection(SettingsSection?)
case setSystemNotificationsEnabled(Bool)
case setAppVisibility(AppVisibility)
case setAutomatedActionPolicy(AutomatedActionPolicy)
case showNotificationPermissionAlert(errorMessage: String?)
case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?)
Expand Down Expand Up @@ -305,6 +309,7 @@ public struct SettingsFeature {
state.confirmQuitMode = normalizedSettings.confirmQuitMode
state.terminateSessionsOnQuit = normalizedSettings.terminateSessionsOnQuit
state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled
state.appVisibility = normalizedSettings.appVisibility
state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? ""
state.syncGlobalDefaults(from: normalizedSettings)
synchronizeRepositorySelection(for: &state)
Expand All @@ -331,6 +336,14 @@ public struct SettingsFeature {
state.syncGlobalDefaults(from: state.globalSettings)
return persist(state)

case .setAppVisibility(let visibility):
// MenuBarExtra echoes the current value on every scene evaluation;
// persisting each echo would loop scene -> persist -> scene.
guard state.appVisibility != visibility else { return .none }
state.appVisibility = visibility
state.syncGlobalDefaults(from: state.globalSettings)
return persist(state)

case .setAutomatedActionPolicy(let policy):
state.automatedActionPolicy = policy
state.syncGlobalDefaults(from: state.globalSettings)
Expand Down
35 changes: 35 additions & 0 deletions SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import SupacodeSettingsShared
import SwiftUI

/// One card in the Dock/menu-bar visibility picker, mirroring `AppearanceOptionCardView`.
struct AppVisibilityOptionCardView: View {
let visibility: AppVisibility
let isSelected: Bool
let action: () -> Void

var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Image(visibility.imageName)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(.rect(cornerRadius: 8))
.accessibilityLabel(visibility.title)
.overlay {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
isSelected ? Color.accentColor : .clear,
lineWidth: 2
)
}
Text(visibility.title)
.font(.callout)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.foregroundStyle(isSelected ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help(visibility.help)
}
}
14 changes: 14 additions & 0 deletions SupacodeSettingsFeature/Views/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ public struct AppearanceSettingsView: View {
)
}
}
Section {
LabeledContent("Visibility") {
HStack(spacing: 12) {
ForEach(AppVisibility.allCases) { visibility in
AppVisibilityOptionCardView(
visibility: visibility,
isSelected: visibility == store.appVisibility
) {
store.send(.setAppVisibility(visibility))
}
}
}
}
}
Section("Editor") {
// The stored id deliberately keeps naming an uninstalled editor, so the choice
// survives a reinstall. No row is tagged with it though, and an untagged
Expand Down
47 changes: 47 additions & 0 deletions SupacodeSettingsShared/Models/AppVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// Where Supacode shows up on the system: the Dock, the menu bar, or both.
/// Every case keeps at least one surface enabled (there is no "hidden
/// everywhere" case), so the app is never unreachable.
public enum AppVisibility: String, CaseIterable, Identifiable, Codable, Sendable {
case dock
case menuBar
case dockAndMenuBar

public var id: String { rawValue }

public var title: String {
switch self {
case .dock: "Dock"
case .menuBar: "Menu Bar"
case .dockAndMenuBar: "Both"
}
}

public var help: String {
switch self {
case .dock: "Show Supacode in the Dock only."
case .menuBar: "Show Supacode in the menu bar only, with no Dock icon."
case .dockAndMenuBar: "Show Supacode in both the Dock and the menu bar."
}
}

public var imageName: String {
switch self {
case .dock: "VisibilityDock"
case .menuBar: "VisibilityMenuBar"
case .dockAndMenuBar: "VisibilityBoth"
}
}

public var showsMenuBarIcon: Bool {
self == .dockAndMenuBar || self == .menuBar
}

public var showsDockIcon: Bool {
self != .menuBar
}

/// Hiding the Dock icon means running as an accessory app.
public var hidesDockIcon: Bool {
!showsDockIcon
}
}
15 changes: 13 additions & 2 deletions SupacodeSettingsShared/Models/GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
/// When true, remote surfaces wrap their session in zmx on the host when
/// the host has it installed, so the session survives disconnects.
public var remoteSessionPersistenceEnabled: Bool
/// Where Supacode appears: Dock, menu bar, or both.
public var appVisibility: AppVisibility

public static let `default` = GlobalSettings(
appearanceMode: .dark,
Expand Down Expand Up @@ -104,7 +106,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
autoUpdateAgentIntegrationsEnabled: true,
confirmQuitMode: .auto,
terminateSessionsOnQuit: false,
remoteSessionPersistenceEnabled: true
remoteSessionPersistenceEnabled: true,
appVisibility: .dock
)

public init(
Expand Down Expand Up @@ -140,7 +143,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
autoUpdateAgentIntegrationsEnabled: Bool = true,
confirmQuitMode: ConfirmQuitMode = .auto,
terminateSessionsOnQuit: Bool = false,
remoteSessionPersistenceEnabled: Bool = true
remoteSessionPersistenceEnabled: Bool = true,
appVisibility: AppVisibility = .dock
) {
self.appearanceMode = appearanceMode
self.defaultEditorID = defaultEditorID
Expand Down Expand Up @@ -175,6 +179,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
self.confirmQuitMode = confirmQuitMode
self.terminateSessionsOnQuit = terminateSessionsOnQuit
self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled
self.appVisibility = appVisibility
}

/// Keys for reading renamed settings fields that no longer
Expand Down Expand Up @@ -337,5 +342,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
remoteSessionPersistenceEnabled =
try container.decodeIfPresent(Bool.self, forKey: .remoteSessionPersistenceEnabled)
?? Self.default.remoteSessionPersistenceEnabled
// Reject unrecognized values (and a mistyped key) from corrupted or
// hand-edited settings files: a throw here resets the whole file to defaults.
appVisibility =
((try? container.decodeIfPresent(String.self, forKey: .appVisibility)) ?? nil)
.flatMap(AppVisibility.init(rawValue:))
?? Self.default.appVisibility
}
}
39 changes: 38 additions & 1 deletion supacode/App/WindowSurfacing.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import AppKit
import SupacodeSettingsShared

private let visibilityLogger = SupaLogger("AppVisibility")

extension NSApplication {
/// Applies the Dock/menu-bar visibility mode: `.menuBar` runs as an accessory
/// (no Dock icon), every other mode as a regular app. Returns false when
/// AppKit refuses the switch.
@MainActor
@discardableResult
func applyActivationPolicy(for visibility: AppVisibility) -> Bool {
let policy: NSApplication.ActivationPolicy = visibility.hidesDockIcon ? .accessory : .regular
guard activationPolicy() != policy else { return true }
if setActivationPolicy(policy) { return true }
// AppKit refuses `.accessory` -> `.regular` while the app is inactive, so
// retry once activated. Only that direction: force-activating to *hide* the
// Dock icon would pop the app to the front for a quieting action.
guard policy == .regular else {
visibilityLogger.error("setActivationPolicy(.accessory) refused; app stays regular.")
return false
}
activate()
guard setActivationPolicy(policy) else {
visibilityLogger.error("setActivationPolicy(.regular) refused; app stays accessory.")
return false
}
return true
}

/// Brings the main window forward, deminiaturizing if needed.
///
/// Falls back to any non-`NSPanel`, non-settings window so a stale
Expand All @@ -25,10 +52,20 @@ extension NSApplication {
if let window = windows.first(where: { $0.identifier?.rawValue == WindowID.main }) {
return window
}
let candidates = windows.filter { !($0 is NSPanel) }
let candidates = windows.filter(\.isSurfaceableAppWindow)
if let window = candidates.first(where: { $0.identifier?.rawValue != WindowID.settings }) {
return window
}
return candidates.first
}
}

extension NSWindow {
/// A real app window the user can be sent to. Excludes `NSPanel` (the shared
/// color / font panels) and anything that can't take main status, notably the
/// status item's window, which would otherwise pass for a visible main window
/// once the menu bar extra is inserted.
var isSurfaceableAppWindow: Bool {
canBecomeMain && !(self is NSPanel)
}
}
37 changes: 32 additions & 5 deletions supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,20 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate {
// the main window. Opt the singleton out per-process so a panel
// left open from a previous session can't survive the relaunch.
NSColorPanel.shared.isRestorable = false
appStore?.send(.appLaunched)
guard let appStore else {
SupaLogger("App").error("applicationDidFinishLaunching with no store; launch setup skipped.")
return
}
// Apply the saved Dock/menu-bar visibility before the first window shows.
NSApplication.shared.applyActivationPolicy(for: appStore.state.settings.appVisibility)
appStore.send(.appLaunched)
}

func applicationDidBecomeActive(_ notification: Notification) {
appStore?.send(.applicationDidBecomeActive)
let app = NSApplication.shared
// Filter `NSPanel` out of the visibility check — the system
// color / font panels (and any sheet-attached child panels) are
// not "main windows" that should suppress surfacing.
let hasVisibleMainWindow = app.windows.contains { window in
window.isVisible && !(window is NSPanel)
window.isVisible && window.isSurfaceableAppWindow
}
guard !hasVisibleMainWindow else { return }
app.surfaceMainWindow()
Expand Down Expand Up @@ -270,6 +273,9 @@ struct SupacodeApp: App {
markNotificationRead: { worktreeID, notificationID in
terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID)
},
markAllNotificationsRead: {
terminalManager.markAllNotificationsRead()
},
hasInflightBlockingScripts: {
terminalManager.hasInflightBlockingScripts
},
Expand Down Expand Up @@ -578,5 +584,26 @@ struct SupacodeApp: App {
.windowToolbarStyle(.unified)
.defaultSize(width: 720, height: 640)
.restorationBehavior(.disabled)
MenuBarExtra(isInserted: menuBarInserted) {
MenuBarNotificationsMenu(store: store)
} label: {
MenuBarNotificationsLabel(store: store)
}
// `.window`, not `.menu`: a native menu item can't host the sidebar row's
// dots, agent badges, and diff stats. The panel is styled to read like a menu.
.menuBarExtraStyle(.window)
}

/// Dragging the status item out of the menu bar falls back to `.dock`, so at
/// least one surface stays enabled.
private var menuBarInserted: Binding<Bool> {
Binding(
get: { store.settings.appVisibility.showsMenuBarIcon },
set: { newValue in
// Ignore MenuBarExtra's scene-evaluation echo; only a real flip should persist.
guard newValue != store.settings.appVisibility.showsMenuBarIcon else { return }
store.send(.settings(.setAppVisibility(newValue ? .dockAndMenuBar : .dock)))
}
)
}
}
16 changes: 16 additions & 0 deletions supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "MenuBarSC.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
6 changes: 6 additions & 0 deletions supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "VisibilityBoth.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "VisibilityBoth@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions supacode/Assets.xcassets/VisibilityDock.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "VisibilityDock.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "VisibilityDock@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading