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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream<Event>
- Prefer `@Shared` directly in reducers for app storage and shared settings; do not introduce new dependency clients solely to wrap `@Shared`.
- Use `SupaLogger` for all logging. Never use `print()` or `os.Logger` directly. `SupaLogger` prints in DEBUG and uses `os.Logger` in release.
- Avoid top-level free functions. Default to `static` methods, computed properties, or instance methods on a relevant type (enum/struct/extension). Free functions pollute the module namespace, are harder to discover, and easily drift from the inline implementation a consumer ends up writing instead. If the operation is pure and stateless, make it a `static` on a caseless `enum` or the most relevant type, not a top-level `func`.
- Closure-typed focused values invalidate the AppKit menu on every body run (closures have no Equatable conformance, so SwiftUI re-publishes every time). Always wrap menu-bar action closures with `FocusedAction<Input>` and publish via `.focusedSceneAction(_:enabled:token:perform:)` / `.focusedAction(_:enabled:token:perform:)`. The wrapper dedupes on `(isEnabled, token)`, so AppKit only rebuilds the menu when something the menu actually displays changes. Token rules in `App/Models/FocusedAction.swift`: set `token` to a hashable projection of any captured state that affects behavior; leave it `nil` when the closure captures only the store / `@State` bindings. Consumers should read the action with `@FocusedValue(\.x)` and gate with `action?.isEnabled != true`, not `action == nil`.

### Formatting & Linting

Expand Down
100 changes: 71 additions & 29 deletions supacode/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import SupacodeSettingsShared
import SwiftUI
import UniformTypeIdentifiers

#if DEBUG
private nonisolated let contentRenderLogger = SupaLogger("DetailRender")
#endif

struct ContentView: View {
@Bindable var store: StoreOf<AppFeature>
@Bindable var repositoriesStore: StoreOf<RepositoriesFeature>
Expand All @@ -25,7 +29,10 @@ struct ContentView: View {
}

var body: some View {
NavigationSplitView(columnVisibility: $leftSidebarVisibility) {
#if DEBUG
let _ = contentRenderLogger.info("ContentView.body re-rendered")
#endif
return NavigationSplitView(columnVisibility: $leftSidebarVisibility) {
SidebarView(store: repositoriesStore, terminalManager: terminalManager)
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320)
.safeAreaInset(edge: .bottom, spacing: 0) {
Expand All @@ -35,7 +42,7 @@ struct ContentView: View {
WorktreeDetailView(store: store, terminalManager: terminalManager)
}
.navigationSplitViewStyle(.automatic)
.disabled(!store.repositories.isInitialLoadComplete)
.disabled(!repositoriesStore.isInitialLoadComplete)
.onChange(of: scenePhase) { _, newValue in
store.send(.scenePhaseChanged(newValue))
}
Expand Down Expand Up @@ -78,45 +85,80 @@ struct ContentView: View {
) { customizationStore in
RepositoryCustomizationView(store: customizationStore)
}
.focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar)
.focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction)
.focusedSceneAction(\.toggleLeftSidebarAction, enabled: true) {
withAnimation(.easeOut(duration: 0.2)) {
leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly
}
}
.focusedSceneAction(
\.revealInSidebarAction,
enabled: repositoriesStore.selectedWorktreeID != nil
) {
withAnimation(.easeOut(duration: 0.2)) {
leftSidebarVisibility = .all
}
store.send(.repositories(.revealSelectedWorktreeInSidebar))
}
.overlay {
CommandPaletteOverlayView(
store: store.scope(state: \.commandPalette, action: \.commandPalette),
items: CommandPaletteFeature.commandPaletteItems(
from: store.repositories,
ghosttyCommands: ghosttyShortcuts.commandPaletteEntries,
scripts: store.allScripts,
runningScriptIDs: store.runningScriptIDs
)
CommandPaletteOverlayHost(
store: store,
repositoriesStore: repositoriesStore,
ghosttyShortcuts: ghosttyShortcuts
)
}
.background(WindowTabbingDisabler())
.background(WindowChromeObserver(runtime: terminalManager.ghosttyRuntime))
.navigationTitle(
WindowTitle.compute(
repositories: store.repositories,
.background(
WindowTitleHost(
repositoriesStore: repositoriesStore,
terminalManager: terminalManager
)
)
}
}

private func toggleLeftSidebar() {
withAnimation(.easeOut(duration: 0.2)) {
leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly
}
}
/// Hosts the command palette overlay so the items build runs in this view's
/// body instead of `ContentView.body`. Per-row sidebar mutations only
/// invalidate this host, leaving ContentView's focused-value closures stable.
private struct CommandPaletteOverlayHost: View {
let store: StoreOf<AppFeature>
let repositoriesStore: StoreOf<RepositoriesFeature>
let ghosttyShortcuts: GhosttyShortcutManager

private var revealInSidebarAction: (() -> Void)? {
guard store.repositories.selectedWorktreeID != nil else { return nil }
return { revealInSidebar() }
var body: some View {
#if DEBUG
let _ = contentRenderLogger.info("CommandPaletteOverlayHost.body re-rendered")
#endif
return CommandPaletteOverlayView(
store: store.scope(state: \.commandPalette, action: \.commandPalette),
items: CommandPaletteFeature.commandPaletteItems(
from: repositoriesStore.state,
ghosttyCommands: ghosttyShortcuts.commandPaletteEntries,
scripts: store.allScripts,
runningScriptIDs: store.runningScriptIDs
)
)
}
}

private func revealInSidebar() {
withAnimation(.easeOut(duration: 0.2)) {
leftSidebarVisibility = .all
}
store.send(.repositories(.revealSelectedWorktreeInSidebar))
}
/// Hosts the `.navigationTitle` modifier so the title computation runs in
/// this view's body. `WindowTitle.compute` reads selection / sidebar.sections
/// fields. Confining the reads here keeps ContentView immune to title-only
/// invalidations from tab renames or section title edits.
private struct WindowTitleHost: View {
let repositoriesStore: StoreOf<RepositoriesFeature>
let terminalManager: WorktreeTerminalManager

var body: some View {
#if DEBUG
let _ = contentRenderLogger.info("WindowTitleHost.body re-rendered")
#endif
return Color.clear
.navigationTitle(
WindowTitle.compute(
repositories: repositoriesStore.state,
terminalManager: terminalManager
)
)
}
}
76 changes: 76 additions & 0 deletions supacode/App/Models/FocusedAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import SwiftUI

/// Equatable wrapper around a focused-value action closure.
///
/// SwiftUI's `focusedSceneValue` / `focusedValue` re-publishes whenever the
/// stored value's identity changes. A bare `() -> Void` closure has no
/// Equatable conformance, so every publisher-view body run looks like a
/// "value changed" event to AppKit, which then rebuilds the system menu and
/// drops open-submenu / hover state (#289). Wrapping the closure in this
/// Equatable adapter keeps the focused value stable across no-op body runs.
///
/// **Contract**: `token` must hash any captured state that affects the
/// closure's behavior. If the closure captures only stable references
/// (the store, projected `@State` bindings), `token` can stay `nil`. If it
/// captures a list of targets, an alert payload, etc., set `token` to a
/// hashable projection of those values so a real change triggers a republish.
struct FocusedAction<Input>: Equatable {
let isEnabled: Bool
let token: AnyHashable?
private let perform: (Input) -> Void

init(
isEnabled: Bool,
token: AnyHashable? = nil,
perform: @escaping (Input) -> Void
) {
self.isEnabled = isEnabled
self.token = token
self.perform = perform
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.isEnabled == rhs.isEnabled && lhs.token == rhs.token
}

func callAsFunction(_ input: Input) {
guard isEnabled else { return }
perform(input)
}
}

extension FocusedAction where Input == Void {
func callAsFunction() {
callAsFunction(())
}
}

extension View {
/// Publishes a stable `FocusedAction` through `focusedSceneValue`.
/// Prefer this over a raw closure: AppKit only sees a "value changed"
/// event when `enabled` or `token` flip, instead of on every body run.
func focusedSceneAction<Input>(
_ keyPath: WritableKeyPath<FocusedValues, FocusedAction<Input>?>,
enabled: Bool,
token: AnyHashable? = nil,
perform: @escaping (Input) -> Void
) -> some View {
focusedSceneValue(
keyPath,
FocusedAction(isEnabled: enabled, token: token, perform: perform)
)
}

/// `focusedValue` variant. Same contract as `focusedSceneAction`.
func focusedAction<Input>(
_ keyPath: WritableKeyPath<FocusedValues, FocusedAction<Input>?>,
enabled: Bool,
token: AnyHashable? = nil,
perform: @escaping (Input) -> Void
) -> some View {
focusedValue(
keyPath,
FocusedAction(isEnabled: enabled, token: token, perform: perform)
)
}
}
14 changes: 14 additions & 0 deletions supacode/Clients/Terminal/TerminalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ struct TerminalClient {
/// Per-worktree projection emitted when surfaces / task-running / unseen / notifications drift.
/// Routed by the parent into the matching `SidebarItemFeature` via the row's id.
case worktreeProjectionChanged(Worktree.ID, WorktreeRowProjection)
/// Per-tab projection emitted when a tab's surfaces, focused pane, or unread
/// count drifts. Routed into the matching `TerminalTabFeature.State` via tab id.
case tabProjectionChanged(worktreeID: Worktree.ID, WorktreeTabProjection)
/// A tab was destroyed in the worktree state. Parent removes the matching
/// `TerminalTabFeature.State` from `terminalTabs`.
case tabRemoved(worktreeID: Worktree.ID, tabID: TerminalTabID)
/// The entire `WorktreeTerminalState` was torn down (worktree pruned).
/// Parent drops any orphan `terminalTabs` entries and removed-tab FIFO
/// records owned by this worktree so a fresh re-attach starts clean.
case worktreeStateTornDown(worktreeID: Worktree.ID)
/// A tab's stripe-progress display flipped. Routed into the matching
/// `TerminalTabFeature.State.progressDisplay` so the stripe recolors.
case tabProgressDisplayChanged(
worktreeID: Worktree.ID, tabID: TerminalTabID, display: TerminalTabProgressDisplay?)
/// Forwarded from the terminal manager when surfaces close (single or bulk).
/// `AppFeature` translates this into `agentPresence(.surfaceClosed/surfacesClosed)`.
case surfacesClosed(Set<UUID>)
Expand Down
12 changes: 6 additions & 6 deletions supacode/Commands/SidebarCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ struct SidebarCommands: Commands {
}
.appKeyboardShortcut(toggleLeftSidebar)
.help("Toggle Left Sidebar (\(toggleLeftSidebar?.display ?? "none"))")
.disabled(toggleLeftSidebarAction == nil)
.disabled(toggleLeftSidebarAction?.isEnabled != true)
Button("Reveal in Sidebar") {
revealInSidebarAction?()
}
.appKeyboardShortcut(revealInSidebar)
.help("Reveal in Sidebar (\(revealInSidebar?.display ?? "none"))")
.disabled(revealInSidebarAction == nil)
.disabled(revealInSidebarAction?.isEnabled != true)
Section {
Menu("Group Relevant Sidebar Rows") {
Toggle("Group Pinned Rows", isOn: groupPinnedRowsToggle)
Expand All @@ -96,20 +96,20 @@ struct SidebarCommands: Commands {
}

private struct ToggleLeftSidebarActionKey: FocusedValueKey {
typealias Value = () -> Void
typealias Value = FocusedAction<Void>
}

private struct RevealInSidebarActionKey: FocusedValueKey {
typealias Value = () -> Void
typealias Value = FocusedAction<Void>
}

extension FocusedValues {
var toggleLeftSidebarAction: (() -> Void)? {
var toggleLeftSidebarAction: FocusedAction<Void>? {
get { self[ToggleLeftSidebarActionKey.self] }
set { self[ToggleLeftSidebarActionKey.self] = newValue }
}

var revealInSidebarAction: (() -> Void)? {
var revealInSidebarAction: FocusedAction<Void>? {
get { self[RevealInSidebarActionKey.self] }
set { self[RevealInSidebarActionKey.self] = newValue }
}
Expand Down
Loading
Loading